diff --git a/.github/pr-assets/6424-after.svg b/.github/pr-assets/6424-after.svg deleted file mode 100644 index dbeb594a09da..000000000000 --- a/.github/pr-assets/6424-after.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/.github/pr-assets/6424-before.svg b/.github/pr-assets/6424-before.svg deleted file mode 100644 index 6b365bad6e69..000000000000 --- a/.github/pr-assets/6424-before.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/.github/pr-assets/6503-after.svg b/.github/pr-assets/6503-after.svg deleted file mode 100644 index db1c9cb54065..000000000000 --- a/.github/pr-assets/6503-after.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62b7fbb134df..c8aa343f74ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,14 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Reject repository-owned PR assets + run: | + files="$(git ls-files .github/pr-assets)" + if test -n "$files"; then + printf 'PR evidence must be uploaded to GitHub, not committed:\n%s\n' "$files" >&2 + exit 1 + fi + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -31,11 +39,6 @@ jobs: cache: true run-install: true - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -45,9 +48,6 @@ jobs: - name: Typecheck run: vpr typecheck - - name: Check resource monitor formatting - run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check - - name: Build desktop pipeline run: vp run build:desktop @@ -57,6 +57,11 @@ jobs: 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 + # Everything except `t3` (apps/server). `--parallel` drops the package + # dependency ordering that `vp run` applies by default: these `test` tasks + # declare no `dependsOn` and resolve workspace deps from source, so ordering + # only bought us idle runners between dependency layers. The concurrency + # limit stays at the default 4 so peak load per runner is unchanged. test: name: Test runs-on: ubuntu-24.04 @@ -77,20 +82,65 @@ jobs: cache: true run-install: true - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + - name: Test + run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test + + # apps/server sets `fileParallelism: false`, so its 239 files run strictly + # one at a time. Sharding spreads them over separate runners instead of + # separate workers, so no two server test files ever share a machine and the + # isolation that flag buys is preserved exactly. + test_server: + name: Test Server ${{ matrix.shard }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + 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: true + + # No Electron setup here: `t3` (apps/server) has no Electron dependency + # and none of its tests touch the runtime. Only the non-server `test` + # job, which covers @t3tools/desktop, needs the download. - name: Test env: T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json - run: vp run test + run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} - - name: Publish transfer budget report + # src/server.test.ts writes the budget report, so exactly one shard + # produces these files. Gating the upload on their presence keeps a + # single `thread-transfer-results` artifact per run, which is the name + # thread-transfer-report.yml resolves. + - name: Detect transfer budget report + id: transfer_budget if: always() + run: | + if test -f "${{ runner.temp }}/thread-transfer-result.json"; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish transfer budget report + if: always() && steps.transfer_budget.outputs.present == 'true' run: | if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md" @@ -99,7 +149,7 @@ jobs: fi - name: Upload thread transfer result - if: always() + if: always() && steps.transfer_budget.outputs.present == 'true' uses: actions/upload-artifact@v7 with: name: thread-transfer-results @@ -107,11 +157,116 @@ jobs: if-no-files-found: ignore retention-days: 30 + # Split out of Check and Test: both paid ~7-9s to install a Rust toolchain + # for checks that take under 3s, on the critical path of every PR. + rust: + name: Rust + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check resource monitor formatting + run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check + - name: Test resource monitor run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml + # The static analysis below needs a macOS runner, which bills ~6.7x a Linux + # minute, so gate it on the native sources it actually lints instead of paying + # for it on every push. Detection is API-only (no checkout) and fails open: if + # the diff cannot be resolved, the lint runs. + mobile_native_changes: + name: Mobile Native Changes + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + changed: ${{ steps.detect.outputs.changed }} + steps: + - name: Detect mobile native changes + id: detect + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BEFORE_SHA: ${{ github.event.before }} + run: | + set -uo pipefail + + fail_open() { + echo "$* Running native static analysis." + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 + } + + count_rows() { + printf '%s\n' "$1" | grep -c . || true + } + + # One row per changed file, holding the new path and, for a rename, + # the path it replaced: renaming a matched file out of the matched + # paths removes a lint input just like editing it. + row='[.filename, (.previous_filename // empty)] | @tsv' + + if [[ -n "${PR_NUMBER}" ]]; then + # The PR files endpoint stops at 3000 files and pagination cannot + # extend it, so cross-check against the count the PR itself reports. + expected=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.changed_files') \ + || fail_open "Could not read the pull request." + rows=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq ".[] | ${row}") \ + || fail_open "Could not resolve changed files." + + listed=$(count_rows "$rows") + if [[ "$listed" -lt "$expected" ]]; then + fail_open "GitHub listed only ${listed} of ${expected} changed files." + fi + else + rows=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BEFORE_SHA}...${GITHUB_SHA}" --jq ".files[]? | ${row}") \ + || fail_open "Could not resolve changed files." + + # The compare endpoint reports at most 300 files and pagination does + # not extend that list, so a full list may be hiding native changes. + listed=$(count_rows "$rows") + if [[ "$listed" -ge 300 ]]; then + fail_open "GitHub listed ${listed} changed files, the compare endpoint maximum." + fi + fi + + paths=$(tr '\t' '\n' <<< "$rows") + + # Sources scripts/mobile-native-static-check.ts lints, plus the tool + # and rule configuration that decides how it lints them, plus the + # root package.json that defines the lint:mobile command. + pattern='^apps/mobile/.*\.(swift|kt|kts)$|^apps/mobile/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts/mobile-native-static-check\.ts$|^package\.json$|^\.github/workflows/ci\.yml$' + + if grep -qE "$pattern" <<< "$paths"; then + echo "Native sources or lint configuration changed:" + grep -E "$pattern" <<< "$paths" + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "No mobile native sources or lint configuration changed." + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + mobile_native_static_analysis: name: Mobile Native Static Analysis + needs: mobile_native_changes + # Skip only on an explicit "no": a gate job that failed or errored leaves the + # output empty, and that must run the lint rather than silently skip it. + if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }} runs-on: macos-15 timeout-minutes: 10 steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f736cdcc7167..0097cd87d496 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,9 +103,6 @@ jobs: cache: true run-install: true - - name: Ensure Electron runtime is installed - run: vp run --filter @t3tools/desktop ensure:electron - - id: release_meta name: Resolve release version shell: bash @@ -160,6 +157,40 @@ jobs: fi fi + - id: previous_tag + name: Resolve previous release tag + run: | + node scripts/resolve-previous-release-tag.ts \ + --channel "${{ steps.release_meta.outputs.release_channel }}" \ + --current-tag "${{ steps.release_meta.outputs.tag }}" \ + --github-output + + quality: + name: Release quality checks + needs: [preflight] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + 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: true + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + - name: Check run: vp check @@ -169,14 +200,6 @@ jobs: - name: Test run: vp run test - - id: previous_tag - name: Resolve previous release tag - run: | - node scripts/resolve-previous-release-tag.ts \ - --channel "${{ steps.release_meta.outputs.release_channel }}" \ - --current-tag "${{ steps.release_meta.outputs.tag }}" \ - --github-output - relay_public_config: name: Resolve T3 Connect public config needs: preflight @@ -388,14 +411,34 @@ jobs: uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/desktop... - - --filter=t3... - - --filter=@t3tools/scripts... + cache: ${{ matrix.platform != 'win' }} + run-install: false + + - name: Resolve Windows package cache path + if: matrix.platform == 'win' + id: package_cache_path + shell: pwsh + run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' + + - name: Cache Windows packages + if: matrix.platform == 'win' + uses: actions/cache@v6 + with: + path: ${{ steps.package_cache_path.outputs.path }} + key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + + - 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/${{ matrix.rust_target }}/release/t3-resource-monitor${{ matrix.platform == 'win' && '.exe' || '' }} + key: resource-monitor-${{ matrix.rust_target }}-${{ 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: ${{ matrix.rust_target }} @@ -521,6 +564,7 @@ jobs: - name: Build desktop artifact shell: bash env: + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} @@ -667,9 +711,9 @@ jobs: publish_cli: name: Publish CLI to npm - needs: [preflight, relay_public_config, build] - if: ${{ !failure() && !cancelled() && inputs.artifacts_only != true && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} - runs-on: ubuntu-24.04 # ubuntu-24.04 + needs: [preflight, relay_public_config, quality, build] + if: ${{ !failure() && !cancelled() && inputs.artifacts_only != true && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' }} + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read diff --git a/.gitignore b/.gitignore index 2d47778818bb..cb88116550de 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ __screenshots__/ squashfs-root/ .vercel .gstack/ +.plans/ dist-electron/ .electron-runtime/ @@ -34,6 +35,7 @@ dist-electron/ .showcase/ apps/mobile/.showcase/ artifacts/app-store/screenshots/ +.github/pr-assets/ native/**/target/ node_modules/ .alchemy/ diff --git a/.macroscope/approvability.md b/.macroscope/approvability.md new file mode 100644 index 000000000000..cfea7fdd57c2 --- /dev/null +++ b/.macroscope/approvability.md @@ -0,0 +1 @@ +Use Macroscope's default approvability criteria. diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 542e9028d36f..b76d56d45dbc 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -68,6 +68,7 @@ Review changed TypeScript and directly affected call sites for the conventions b - Export direct schema predicates such as `export const isFoo = Schema.is(Foo)`. Flag a private `Schema.is` constant wrapped by a redundant function with the same signature. - Do not introduce a large `switch` or lookup table in an error's `message` getter to model failures that deserve separate error classes. - Catch statically known tagged failures with `Effect.catchTags({ ... })`, including when handling only one tag. Do not use `catchIf` with a schema predicate merely to recover one or more known `_tag` variants, and do not use `catchTag`. `Effect.catch` is appropriate when the entire error channel is intentionally handled; `catchIf` remains appropriate for genuinely structural predicates such as inspecting an underlying platform error code. +- For startup reconciliation that repairs multiple independent entities, preserve interruption rather than reducing it to a warning. Retry a transient per-entity repair before readiness, then isolate a persistent failure so one bad entity cannot abort global startup or prevent later entities from being repaired. Require tests for both the retry-success path and persistent-failure continuation. - Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`, including curried aliases used once with `mapError`. Construct the error at the failure boundary so its attributes and cause remain visible. Keep a mapper only when it performs real normalization, passes through existing domain errors, or adds reusable context/control flow. - When a reusable error-to-error translation clearly belongs to the target error type, prefer a descriptive static factory on that error class over a detached production-side switch. Do not force a static method for one-off inline mappings. diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 8ec720742759..c2c091b205cf 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -47,6 +47,9 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put - light-only declarations use `@variant light`; - raw `.dark` should remain only in the `dark` and `light` custom-variant definitions. - Preserve custom themes and runtime token bridges. Removing a variable or selector is safe only when all runtime, inspector, generated, and theme-palette consumers are accounted for. +- Contrast and accessibility settings that target app chrome must derive from semantic color tokens. Do not apply `filter` to `html`, `body`, or the app root: it also changes user media, previews, terminals, glass backdrop ownership, and view-transition snapshots. +- Preserve alpha and surface ownership when deriving contrast tokens. Soften translucent borders and inputs toward transparent rather than an opaque canvas, use a modest semantic-foreground mix for stronger borders, and adjust card, popover, accent, secondary, and message foregrounds against their own surfaces when the base foreground changes. +- Runtime-adjusted roles must be ordinary custom properties shared by the Tailwind bridge, global CSS, imperative style strings, and bridge snapshots sent to other renderers. Audit literal `var(--foreground)`, `var(--border)`, and related role reads so headings, markdown chrome, menus, previews, and utilities do not split into adjusted and unadjusted colors. - Inspect emitted production CSS after unusual variants, arbitrary selectors, nested pseudo-elements, or attribute matching. Source syntax that looks valid is insufficient. - Flag malformed or empty emitted selectors such as empty `:is()` or `:not(:is())`, selector branches that can never match their own class attribute, and transformations that silently drop the intended rule. - Prefer source-level logic over clever selectors when behavior depends on consumer-provided class strings. Preserve `MenuPopup`'s current defaulting contract: a string `className` containing a `w-*`, `min-w-*`, or `max-w-*` utility after variant prefixes are stripped suppresses `min-w-32`; a string without one and a functional/non-string `className` keep the default. Arbitrary width values count as width utilities, and the consumer class must be merged last so it retains control. Do not replace this with a raw class-attribute substring selector. diff --git a/AGENTS.md b/AGENTS.md index bcd2979c7441..d482e5318d91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,76 @@ pingdotgg/t3code:main `package.json`. Regenerate the lockfile (`CI= pnpm install --no-frozen-lockfile`) rather than taking `--ours`/`--theirs` on it. +## What makes T3 Code special? + +We have over 200,000 users who love T3 Code. It's important we maintain the things they love as we continue to iterate on the product. Here's a brief list of the things we can never compromise on. + +### 1. Open at the core + +T3 Code is truly open. We share our roadmap, we share how we think about things, and of course we share all our code. A large number of our users run forks. We work in the open, and should strive to stay that way. + +### 2. Performance without compromise + +Lots of apps have gotten bogged down with bad tech decisions and "slop". We have not, and we're proud of the performance of T3 Code. We regularly audit for performance regressions, often caused by sending too much data over websockets, css animations causing gpu spikes, lists being hard to render, and more. Make sure all changes are considerate of performance impact. + +### 3. Remote ready + +The architecture of T3 Code's websocket layer (`npx t3`) enables a lot of awesome remote features. These have become core to the product. Whether users are connecting directly over their local network, using Tailscale, or leaning in fully with T3 Connect (our tunnel solution, also in this repo), we need to make sure new features are properly supported. + +### 4. Multi-surface + +T3 Code has 3 key app surfaces: **web**, **desktop**, and **mobile**. + +**Web** is kind of two surfaces, as we have the public facing "app.t3.codes" as well as locally hosting the web app through the `npx t3` command. Both need to be supported by all new features where reasonable. + +**Desktop** is the main surface most users install first. It's a full Electron app that bundles the server runner as well. The desktop app can also be used as the host server, allowing remote connections from app.t3.codes or the mobile app. + +**Mobile** is a React Native app for both iOS and Android, available on the App Store and Google Play. The mobile app allows for connecting to any T3 Code server to control work remotely. + +## A note from Theo + +I like ambitious ideas, simple systems, and software that feels obvious. Do not preserve complexity just because it already exists. Do not introduce machinery because it looks architecturally impressive. Understand the real constraint, then fight for the smallest model that makes the correct behavior unsurprising. + +Channel both "measure twice, cut once" and "yagni". Fight scope creep. Try to honor the dev's intent in both a minimal and realistic fashion. + +The rest of this document is meant to help you navigate the codebase and make changes effectively. Think of these instructions less as "hard rules", more as "good defaults". The developer's preferences should be able to override anything here. + +Of note: Most T3 Code contributions will come from T3 Code itself, often controlled remotely. This means you should be careful about accessing data, killing dev servers, and other things that may damage the T3 Code instance that the contributor is using. + +## A small glossary + +We need to be on the same page with terminology. When communicating, use this language: + +- **you** means the agent reading this file and changing T3 Code. +- **we, us, and maintainers** mean Theo, Julius and the people building T3 Code. These are who you are talking to now. +- **user** means the person using T3 Code to direct coding agents. +- **agent** means the coding agent a user runs inside T3 Code. Depending on context, that may also include you. +- **provider** means the agent runtime or harness T3 Code talks to, such as Codex, Claude, Cursor, or OpenCode. +- **client** means the web, desktop, or mobile UI. +- **environment** means one running T3 server and the machine, filesystem, provider credentials, and state it owns. +- **project** means an environment-local workspace record rooted at a directory. +- **thread** means the durable conversation and work history for a project. +- **turn** means one user-to-agent cycle, including follow-up work such as checkpointing. +- **T3 home** means the base data directory. Runtime state normally lives below its userdata directory. + +## The three ways to hurt yourself + +1. **Killing by pattern.** Never `pkill -f`, `pgrep | kill`, or `kill` a PID you found by matching a name, path, or worktree string. Your own agent process has this worktree's path in its argv, and this machine runs several other dev servers at once. Kill only a PID you captured at spawn, or the owner of your port from `ss -H -ltnp` after confirming `/proc//cwd` is your worktree. +2. **Writing to the live install.** `~/.t3/userdata` is the developer's real T3 Code database, in use while you work. Reading it and copying from it are fine, and a good way to get real test data (see Test data). Never start a server against it, never open it read-write, never clean it up. +3. **Baking in origins.** Never set `VITE_HTTP_URL` or `VITE_WS_URL` for dev. Dev is single-origin and Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known`. Setting them bakes localhost into the bundle and silently breaks every remote browser. + +## Hit every surface + +The most common defect in this repo is a change that works on the path you tested and is missing everywhere else. Before calling frontend work done, walk this list and say which entries applied: + +- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. +- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime`. +- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. +- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. +- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. +- **Docs.** `docs/` splits by audience. Behavior changes that a user would notice belong in `docs/user/` (shipped-product voice, no repo tooling or source paths); architecture and contributor changes in `docs/internals/`; runbooks in `docs/operations/`; new vocabulary in `docs/internals/glossary.md`. + ## Pull requests (required handoff) When implementation work for a user request is done (code, docs, config — not pure Q&A): @@ -212,12 +282,32 @@ work: ## Dev Servers +- `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. - In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins. - Start the web stack with `vp run dev`. Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, paste that full URL (token included) in your reply. Do not wire up `tailscale serve` by hand for this, and do not open the URL yourself. - The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. If the token got consumed, mint a fresh one with `node apps/server/src/bin.ts pair` — note it carries standard scopes, while the startup URL carries admin scopes (needed for Settings → Connections management). - Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. - Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them. - Before handing off a `--share` URL, open its **origin only** (no path, no token) in a controlled browser and confirm the app loads — never the full pairing URL, whose one-time token the check would consume. A successful curl is insufficient because browsers reject some otherwise reachable ports. +- Stop what you started, by the PID you tracked. See _The three ways to hurt yourself_. + +## Test data + +An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: + +- Copy from `~/.t3/userdata` (the developer's real data, the most realistic test set) or `~/.t3/dev`. Worktree state lives at `/.t3/userdata`. +- Snapshot the database with `VACUUM INTO`, which is safe even while a server has the source open and yields one consistent file: + + ```bash + mkdir -p .t3/userdata + rm -f .t3/userdata/state.sqlite* # VACUUM INTO refuses to overwrite + bun -e "new (require('bun:sqlite').Database)(process.env.HOME + '/.t3/userdata/state.sqlite', { readonly: true }).run(\"VACUUM INTO '.t3/userdata/state.sqlite'\")" + ``` + + A plain `cp` is only safe when no server has the source open, and must bring the `-wal` and `-shm` siblings along. A live file copy is a corrupt copy. + +- Bring `secrets` and `settings.json` only if the flow under test needs them. +- Copy in, never symlink. Data flows one way: into your sandbox, never back out. ## Package Roles @@ -249,6 +339,13 @@ agents. - When writing relay infrastructure code with Alchemy, inspect `.repos/alchemy-effect/` for examples of idiomatic usage, tests, module structure, and API design. +## Plans and work artifacts + +- Do not commit implementation plans, research notes, or agent scratch files. Keep temporary working material outside the worktree. `.plans/` is gitignored only as a safety net for legacy tooling. +- Track active maintainer work in the GitHub issue or project item that owns it. External proposals follow `CONTRIBUTING.md` and belong in Ideas discussions. +- Put durable architecture, constraints, and decisions in `docs/internals/`. Update those docs when the product changes so agents find current facts instead of abandoned intentions. +- A merged PR is the implementation record. Close or update its tracking item when the work lands; do not preserve a second checklist in the repository. + ## How it works Clients send typed WebSocket requests. The server turns them into _commands_, a pure _decider_ turns commands into persisted _events_, and a _projector_ derives the read model the UI renders. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Side effects run in queue-backed _reactors_ that emit _receipts_ when milestones land. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore. @@ -276,3 +373,6 @@ Full glossary with file links: `docs/internals/glossary.md` - Don't verify with browsers or computer use unless the user explicitly agrees or requests it. - Security is important, but should not be over-indexed on, especially for dev mode/maintainer-only features. +- The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. +- Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. One concern per PR — if the description says "also", split it. +- UI changes need before/after images. Motion or timing needs a short video. Upload PR evidence to GitHub. Never commit PR-only screenshots or assets such as `.github/pr-assets/`. diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 1ac8a47fc73e..ac42e0914d0c 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -124,6 +124,34 @@ describe("ElectronDialog", () => { }).pipe(Effect.provide(dialogLayer)), ); + it.effect("opens a single-file picker when multiple selections are disabled", () => + Effect.gen(function* () { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/pictures/icon.png"], + }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const paths = yield* dialog.pickFiles({ + owner: Option.none(), + defaultPath: Option.some("/project"), + filters: [{ name: "Images", extensions: ["png"] }], + multiple: false, + }); + + assert.deepEqual(paths, ["/pictures/icon.png"]); + assert.deepEqual(showOpenDialogMock.mock.calls, [ + [ + { + defaultPath: "/project", + filters: [{ name: "Images", extensions: ["png"] }], + properties: ["openFile"], + }, + ], + ]); + }).pipe(Effect.provide(dialogLayer)), + ); + it.effect("preserves message box request context and cause", () => Effect.gen(function* () { const cause = new Error("message box failed"); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 497ed03a9219..a772f1b1c8c4 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -107,6 +107,7 @@ export interface ElectronDialogPickFilesInput { readonly owner: Option.Option; readonly defaultPath: Option.Option; readonly filters: readonly Electron.FileFilter[]; + readonly multiple: boolean; } export class ElectronDialog extends Context.Service< @@ -176,7 +177,7 @@ export const make = Effect.gen(function* () { }); const defaultPath = Option.getOrNull(input.defaultPath); const openDialogOptions: Electron.OpenDialogOptions = { - properties: ["openFile", "multiSelections"], + properties: input.multiple ? ["openFile", "multiSelections"] : ["openFile"], filters: [...input.filters], ...(defaultPath === null ? {} : { defaultPath }), }; diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index a01ead4e45a5..74f9eb74e205 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -50,6 +50,22 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens remote SSH editor URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal( + "vscode://vscode-remote/ssh-remote+example.com/home/user/project", + ); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [ + ["vscode://vscode-remote/ssh-remote+example.com/home/user/project"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens local editor file/folder URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -76,6 +92,25 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("does not open remote editor URLs with userinfo", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal( + "vscode://user@vscode-remote/ssh-remote+example.com/home/user/project", + ), + electronShell.openExternal( + "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", + ), + ]); + + assert.deepEqual(results, [false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open arbitrary VS Code URLs", () => Effect.gen(function* () { const electronShell = yield* ElectronShell.ElectronShell; @@ -96,6 +131,20 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("does not open non-remote editor URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal( + "vscode://ms-python.python/some-command?argument=attacker", + ); + + assert.equal(result, false); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("returns false when Electron rejects openExternal", () => Effect.gen(function* () { openExternalMock.mockRejectedValue(new Error("open failed")); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index a4e33cda8d60..e91d3035d802 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); +const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); // Editor URL schemes whose handler runs in the user's graphical session, so the desktop can open a // file/folder or a Remote-SSH target even when the t3 server runs headless (e.g. a lingered systemd // user service with no display env). @@ -24,6 +24,26 @@ const SAFE_EDITOR_PROTOCOLS = new Set([ return scheme === undefined ? [] : [`${scheme}:`]; }), ]); +const REMOTE_EDITOR_PROTOCOLS = new Set( + REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { + const scheme = remoteSchemeForEditor(id); + return scheme === undefined ? [] : [`${scheme}:`]; + }), +); + +const isRemoteEditorUrl = (url: URL) => + REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && + url.username.length === 0 && + url.password.length === 0 && + url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length; + +const isLocalEditorFileUrl = (url: URL) => + SAFE_EDITOR_PROTOCOLS.has(url.protocol) && + url.username.length === 0 && + url.password.length === 0 && + url.hostname === "file"; export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { @@ -32,21 +52,11 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { try { const url = new URL(rawUrl); - if (SAFE_EXTERNAL_PROTOCOLS.has(url.protocol)) { - return Option.some(url.href); - } - if (SAFE_EDITOR_PROTOCOLS.has(url.protocol)) { - // Local open: `://file/`. - if (url.hostname === "file") { - return Option.some(url.href); - } - // Remote-SSH open: `://vscode-remote/ssh-remote+/`. - if (url.hostname === "vscode-remote" && url.pathname.startsWith("/ssh-remote+")) { - return Option.some(url.href); - } - return Option.none(); - } - return Option.none(); + return SAFE_WEB_PROTOCOLS.has(url.protocol) || + isRemoteEditorUrl(url) || + isLocalEditorFileUrl(url) + ? Option.some(url.href) + : Option.none(); } catch { return Option.none(); } diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 69adef9f1827..65d9abcc2367 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -39,6 +39,7 @@ import { openExternal, probeRemoteEditors, pickFolder, + pickProjectFavicon, pickThemeFiles, setTheme, showContextMenu, @@ -87,6 +88,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslOnly); yield* ipc.handle(pickFolder); + yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 51d7c4f0bd83..76b63ee72e5f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 13e6e8d39563..203151c2660e 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -2,13 +2,19 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { vi } from "vite-plus/test"; import type * as Electron from "electron"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; +import { + getLocalEnvironmentBootstraps, + getWindowFullscreenState, + pickProjectFavicon, +} from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", @@ -146,3 +152,38 @@ describe("getWindowFullscreenState", () => { ); }); }); + +describe("pickProjectFavicon", () => { + it.effect("opens a single-image picker from the project directory", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon.handler("/project").pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + }), + ), + ), + ); + + assert.strictEqual(result, "/pictures/icon.png"); + assert.deepEqual(pickFiles.mock.calls, [ + [ + { + owner: Option.none(), + defaultPath: Option.some("/project"), + multiple: false, + filters: [ + { + name: "Images", + extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"], + }, + ], + }, + ], + ]); + }), + ); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 0c7e90b95072..edae8394302c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -12,6 +12,7 @@ import { type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; +import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview"; import { isCommandAvailable } from "@t3tools/shared/shell"; import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; @@ -234,6 +235,28 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ }), }); +export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, + payload: Schema.UndefinedOr(Schema.String), + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const paths = yield* dialog.pickFiles({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: Option.fromNullishOr(initialPath), + multiple: false, + filters: [ + { + name: "Images", + extensions: WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.map((extension) => extension.slice(1)), + }, + ], + }); + return paths[0] ?? null; + }), +}); + export const setTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_THEME_CHANNEL, payload: DesktopThemeSchema, @@ -323,6 +346,7 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ owner: yield* electronWindow.focusedMainOrFirst, defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(), filters: [{ name: "JSON", extensions: ["json"] }], + multiple: true, }); if (paths.length === 0) { return null; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 462bee84c9ba..5d0b99574863 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -101,6 +101,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + pickProjectFavicon: (initialPath) => + ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 6ded206a0f77..5eeb19b3ca91 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + appearanceContrast: 100, browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, browserDefaultAppearance: "dark", @@ -48,6 +49,7 @@ const clientSettings: ClientSettings = { ], preferredOpenWith: { type: "custom", id: OpenWithEntryId.make("terminal") }, planModeEnabled: false, + showSkillsInSlashMenu: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarAutoSettleOnMerge: true, diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 9d6bbaea6bcb..78ea56e75131 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -58,9 +58,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("does not throw on out-of-range numeric entities and keeps the literal", () => { - expect(() => - normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"), - ).not.toThrow(); const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); }); diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts index d2a3c774b8a6..fe11ddd4c069 100644 --- a/apps/marketing/vercel.ts +++ b/apps/marketing/vercel.ts @@ -1,6 +1,9 @@ import type { VercelConfig } from "@vercel/config/v1"; export const config: VercelConfig = { + git: { + deploymentEnabled: false, + }, installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", buildCommand: "vp run --filter @t3tools/marketing build", outputDirectory: "dist", diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 84a6ede6ad36..e7ca5b447c64 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; import type { MarkdownNode } from "react-native-nitro-markdown/headless"; @@ -10,10 +10,14 @@ import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios import type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "./SelectableMarkdownText.types"; +/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */ +export const MarkdownImageRendererContext = createContext(null); + type HighlightedCode = ReadonlyArray>; const highlightedCodeCache = new Map(); @@ -382,6 +386,7 @@ function NativeMarkdownImage(props: { readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { + const renderImage = useContext(MarkdownImageRendererContext); const href = props.node.href; if (!href) { return ( @@ -394,6 +399,17 @@ function NativeMarkdownImage(props: { ); } + if (renderImage) { + const rendered = renderImage({ + href, + alt: props.node.alt ?? null, + title: props.node.title ?? null, + }); + if (rendered != null) { + return <>{rendered}; + } + } + return ( = []; export type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, SelectableMarkdownTextProps, @@ -36,6 +38,7 @@ export function SelectableMarkdownText({ highlightCode, preserveSoftBreaks = false, onLinkPress, + renderImage, marginTop = 0, marginBottom = 0, }: SelectableMarkdownTextProps) { @@ -59,38 +62,40 @@ export function SelectableMarkdownText({ }, [markdown, preserveSoftBreaks, skills]); return ( - // A percentage width here creates a cyclic intrinsic measurement inside - // shrink-to-fit containers such as user-message bubbles. Yoga then gives - // the native text node an unbounded second pass and the parent only clips - // the resulting single-line width instead of reflowing it. - - {chunks.map((chunk, index) => { - const content = - chunk.kind === "rich" ? ( - - ) : ( - - ); + + {/* A percentage width here creates a cyclic intrinsic measurement inside + shrink-to-fit containers such as user-message bubbles. Yoga then gives + the native text node an unbounded second pass and the parent only clips + the resulting single-line width instead of reflowing it. */} + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); - return ( - - {content} - - ); - })} - + return ( + + {content} + + ); + })} + + ); } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx index fcb2472f6488..006d33e7259d 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types export type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, SelectableMarkdownTextProps, diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 42cc3cd6fb63..00260b0c4f27 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -36,6 +36,20 @@ export interface SelectableMarkdownSkill { readonly displayName?: string | null; } +export interface MarkdownImageRequest { + readonly href: string; + readonly alt: string | null; + readonly title: string | null; +} + +/** + * App-supplied renderer for markdown images. The module cannot load + * workspace-relative image paths itself — the host app resolves them (for + * example through a signed asset URL) and returns the element to show. + * Returning null falls back to the module's plain remote-URI rendering. + */ +export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; + export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; @@ -43,6 +57,7 @@ export interface SelectableMarkdownTextProps { readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; + readonly renderImage?: MarkdownImageRenderer; readonly marginTop?: number; readonly marginBottom?: number; } 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/package.json b/apps/mobile/package.json index d199e74ae4a3..cbc9e4b86a9e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -83,6 +83,7 @@ "expo-constants": "~56.0.18", "expo-crypto": "~56.0.4", "expo-dev-client": "~56.0.20", + "expo-device": "~56.0.4", "expo-file-system": "~56.0.8", "expo-font": "~56.0.7", "expo-glass-effect": "~56.0.4", diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 28f7cfe57a7f..bfba418c9fce 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -33,6 +33,7 @@ export function brandTitleOffset(nativeLeadingItem: boolean): number { */ export function CompactBrandTitle( props: { + readonly allowFontScaling?: boolean; readonly nativeLeadingItem?: boolean; } = {}, ) { @@ -57,6 +58,7 @@ export function CompactBrandTitle( > ; + return ; } export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] { diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 852535d9d10b..8e699e4c24fd 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -21,6 +21,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import Constants from "expo-constants"; import * as Network from "expo-network"; import { AppState } from "react-native"; @@ -166,7 +167,7 @@ const capabilitiesLayer = Layer.effectContext( Context.add( ClientPresentation, ClientPresentation.of({ - metadata: authClientMetadata(), + metadata: authClientMetadata(Constants.expoConfig?.version), scopes: AuthStandardClientScopes, }), ), diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index c75d60d5fdf8..4d7b0864184b 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -33,6 +33,11 @@ vi.mock("expo-constants", () => ({ }, })); +vi.mock("expo-device", () => ({ + osVersion: "18.4.1", + modelName: "iPhone 15 Pro", +})); + vi.mock("react-native", () => ({ Platform: { OS: "ios", diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 75f78738ade5..6d07f40a52dd 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { describe, expect, it } from "vite-plus/test"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; -import { splitEnvironmentSections } from "./environmentSections"; +import { relayManagedEnvironmentIds, splitEnvironmentSections } from "./environmentSections"; function connectedEnvironment( input: Omit, "environmentId"> & { @@ -34,6 +34,17 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { }; } +describe("relayManagedEnvironmentIds", () => { + it("leaves out a backend that was saved directly", () => { + const ids = relayManagedEnvironmentIds([ + connectedEnvironment({ environmentId: "environment-local", isRelayManaged: false }), + connectedEnvironment({ environmentId: "environment-cloud", isRelayManaged: true }), + ]); + + expect([...ids]).toEqual([EnvironmentId.make("environment-cloud")]); + }); +}); + describe("mobile environment settings sections", () => { it("keeps saved relay-managed connections under T3 Connect", () => { const local = connectedEnvironment({ @@ -111,6 +122,24 @@ describe("mobile environment settings sections", () => { expect(sections.availableCloudEnvironments).toEqual([]); }); + it("still offers a cloud environment saved directly as a local backend", () => { + const local = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: false, + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [local], + cloudEnvironments: [cloudEnvironment("environment-cloud")], + }); + + expect(sections.localEnvironments).toEqual([local]); + expect(sections.connectedCloudEnvironments).toEqual([]); + expect( + sections.availableCloudEnvironments.map((environment) => environment.environmentId), + ).toEqual([EnvironmentId.make("environment-cloud")]); + }); + it("keeps failed relay environments in the local connection row", () => { const cloud = connectedEnvironment({ environmentId: "environment-cloud", diff --git a/apps/mobile/src/features/connection/environmentSections.ts b/apps/mobile/src/features/connection/environmentSections.ts index fc6db479c2ff..10ba636dc576 100644 --- a/apps/mobile/src/features/connection/environmentSections.ts +++ b/apps/mobile/src/features/connection/environmentSections.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; @@ -12,10 +13,25 @@ export interface EnvironmentSections { readonly availableCloudEnvironments: ReadonlyArray; } -export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { - const savedEnvironmentIds = new Set( - input.connectedEnvironments.map((environment) => environment.environmentId), +/** + * Ids of the environments that already occupy a T3 Connect slot. A backend saved directly is + * not one of them, so it must not suppress the cloud environment that happens to share its id. + */ +export function relayManagedEnvironmentIds( + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly isRelayManaged: boolean; + }>, +): ReadonlySet { + return new Set( + environments + .filter((environment) => environment.isRelayManaged) + .map((environment) => environment.environmentId), ); +} + +export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { + const savedEnvironmentIds = relayManagedEnvironmentIds(input.connectedEnvironments); return { localEnvironments: input.connectedEnvironments.filter( diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index bad6b6f17209..faa34477569d 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -20,6 +20,7 @@ import { useEnvironments } from "../../state/environments"; import { relayEnvironmentDiscovery } from "../../state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel"; +import { relayManagedEnvironmentIds } from "./environmentSections"; export interface RelayEnvironmentView { readonly environment: RelayClientEnvironmentRecord; @@ -49,7 +50,7 @@ export function useConnectionController() { [environments], ); const registeredIds = useMemo( - () => new Set(connectedEnvironments.map((environment) => environment.environmentId)), + () => relayManagedEnvironmentIds(connectedEnvironments), [connectedEnvironments], ); const relayEnvironments = useMemo>( diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts index 85383514cb56..7345a7f366c5 100644 --- a/apps/mobile/src/features/files/fileTree.test.ts +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -68,7 +68,7 @@ describe("mobile file tree helpers", () => { const tree = buildFileTree([ { kind: "file", - path: ".plans/19-version-control-phase-1-vcs-driver-foundation.md", + path: "docs/internals/workspace-layout.md", }, { kind: "file", diff --git a/apps/mobile/src/features/home/AndroidHomeFab.tsx b/apps/mobile/src/features/home/AndroidHomeFab.tsx index 5c7d5a0b988c..c57964fce4a3 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.tsx @@ -6,8 +6,8 @@ import { SymbolView } from "../../components/AppSymbol"; import { useThemeColor } from "../../lib/useThemeColor"; /** - * Android-only wrapper that overlays a bottom-right new-task FAB on the home - * screen. Other platforms render children unchanged. + * Android-only wrapper that overlays a bottom-right new-task FAB on a thread + * list. Other platforms render children unchanged. */ export function AndroidHomeFabLayout(props: { readonly onStartNewTask: () => void; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 86ed104845f8..82f27434ea87 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -8,6 +8,7 @@ import { } from "@t3tools/client-runtime/state/identity"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { Platform } from "react-native"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -167,7 +168,13 @@ export function HomeRouteScreen() { if (layout.usesSplitView) { return ( <> - + [] } + } + /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Title is owned by HomeHeader (tracks list mode), which carries the - connection-aware brand slot — no native-stack title to avoid - showing the status twice. */} + {/* Restore the header after leaving split view; screen options are + shallow-merged. Title/brand stay on HomeHeader (list-mode title + + connection-aware slot) so we do not paint the status twice. */} + + ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => { + (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { setChangeRequestByKey((current) => { const existing = current.get(threadKey) ?? null; if ( (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) + (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && + (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) ) { return current; } @@ -607,10 +608,13 @@ export function HomeScreen(props: HomeScreenProps) { () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); - const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); - const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); - const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const { + loaded: shelfPreferencesLoaded, + settledShelfExpanded, + snoozedShelfExpanded, + 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". @@ -976,7 +980,11 @@ export function HomeScreen(props: HomeScreenProps) { ); const renderV2Item = useCallback( - ({ item }: { readonly item: ThreadListV2ListItem }) => { + ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => { + const nextItem = threadListV2Items[index + 1]; + const showTrailingDivider = + nextItem?.type === "v2-thread" || + (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider); if (item.type === "v2-pending") { const pendingScopeKey = scopedProjectKey( item.pendingTask.message.environmentId, @@ -994,6 +1002,7 @@ export function HomeScreen(props: HomeScreenProps) { : null } showPendingDivider={item.showPendingDivider} + showTrailingDivider={showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} onDeletePendingTask={props.onDeletePendingTask} /> @@ -1003,6 +1012,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -1012,6 +1022,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -1033,6 +1044,7 @@ export function HomeScreen(props: HomeScreenProps) { pinned={item.item.pinned} snoozePresetMinute={nowMinute} snoozeWakeLabelText={item.snoozeWakeLabelText} + showTrailingDivider={showTrailingDivider} project={ projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } @@ -1115,7 +1127,9 @@ export function HomeScreen(props: HomeScreenProps) { props.savedConnectionsById, props.searchQuery, serverConfigs, + shelfPreferencesLoaded, settlementEnvironmentIds, + threadListV2Items, threadSearchMatchByKey, titleRegenerationEnvironmentIds, toggleSettledShelf, @@ -1298,7 +1312,7 @@ export function HomeScreen(props: HomeScreenProps) { @@ -1446,7 +1460,7 @@ export function HomeScreen(props: HomeScreenProps) { contentContainerStyle={{ paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 96 + ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance : Math.max(insets.bottom, 16) + 88, }} /> @@ -1505,16 +1519,19 @@ export function HomeScreen(props: HomeScreenProps) { scrollEventThrottle={16} contentContainerStyle={{ // Android reserves room for the floating new-task FAB - // (56 button + 16 gap + bottom inset). + // (56 button + 16 gap + bottom inset). Pre-glass iOS shows a + // standard 44pt bottom toolbar that overlays the list and is not + // reflected in insets while contentInsetAdjustmentBehavior is + // "never". paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 24 + ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance : Math.max(insets.bottom, 16) + 88, }} scrollIndicatorInsets={ Platform.OS === "ios" ? { - bottom: Math.max(insets.bottom, 16) + 24, + bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance, top: 0, } : undefined diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 66928002a120..08ead49a2407 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -46,6 +46,11 @@ import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; +import { + resolveOwnershipFilter, + resolveOwnershipRelation, +} from "../../persistence/mobile-preferences"; +import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider, resolveProjectGroupingMode, @@ -57,10 +62,6 @@ import { resolveHomeThreadGrouping, type HomeThreadGrouping, } from "../home/homeListMode"; -import { - resolveOwnershipFilter, - resolveOwnershipRelation, -} from "../../persistence/mobile-preferences"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -509,6 +510,10 @@ function AdaptiveWorkspaceLayoutContent( }); }, [navigation]); + const handleStartNewTask = useCallback(() => { + navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + }, [navigation]); + // Minted here (root stack navigation) so the sidebar pane stays free of // navigation hooks — on iOS it renders inside an independent nav tree. const handleOpenEnvironmentSettings = useCallback(() => { @@ -615,18 +620,22 @@ function AdaptiveWorkspaceLayoutContent( pointerEvents={panes.primarySidebarVisible ? "auto" : "none"} style={sidebarAnimatedStyle} > - + + + + + ) : null} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index c7e6b534a796..b48c7a0bdd94 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -10,6 +10,8 @@ import { getCloneDestinationBrowsePath, getCloneDestinationPath, getCloneDirectoryName, + getDefaultCloneUrl, + normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, type AddProjectRemoteSource, @@ -662,7 +664,7 @@ export function AddProjectRepositoryScreen(props: { setIsSubmitting(true); const provider = addProjectRemoteSourceProvider(source); if (!provider) { - const remoteUrl = repositoryInput.trim(); + const remoteUrl = normalizePastedCloneUrl(repositoryInput); navigation.dispatch( StackActions.push("AddProjectDestination", { environmentId: environment.environmentId, @@ -691,7 +693,7 @@ export function AddProjectRepositoryScreen(props: { StackActions.push("AddProjectDestination", { environmentId: environment.environmentId, source, - remoteUrl: repository.sshUrl, + remoteUrl: getDefaultCloneUrl(repository), repositoryTitle: repository.nameWithOwner, repositoryName: getCloneDirectoryName(repository.nameWithOwner), }), diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 68ee71d883f3..cbc47c99c2e3 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -119,6 +119,7 @@ const CommandRow = memo(function CommandRow(props: { readonly item: ComposerCommandItem; readonly onPress: () => void; readonly isLast: boolean; + readonly isSlashSkill: boolean; }) { const iconName = itemIcon(props.item); const iconColor = useThemeColor("--color-icon-subtle"); @@ -144,7 +145,14 @@ const CommandRow = memo(function CommandRow(props: { ) : null} - {props.item.label} + {props.isSlashSkill && props.item.type === "skill" ? ( + <> + skill: + {props.item.skill.name} + + ) : ( + props.item.label + )} {props.item.description ? ( @@ -181,6 +189,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( item={item} onPress={() => props.onSelect(item)} isLast={index === props.items.length - 1} + isSlashSkill={props.triggerKind === "slash-command" && item.type === "skill"} /> ))} diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index 377ae82aba8b..fb9cc72d25d3 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -1,4 +1,8 @@ -import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts"; +import type { + ApprovalRequestId, + ProviderApprovalDecision, + ProviderApprovalOption, +} from "@t3tools/contracts"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; @@ -13,7 +17,14 @@ export interface PendingApprovalCardProps { ) => Promise; } +const DEFAULT_APPROVAL_OPTIONS = [ + { decision: "accept", label: "Allow once" }, + { decision: "acceptForSession", label: "Allow session" }, + { decision: "decline", label: "Decline" }, +] satisfies ReadonlyArray; + export function PendingApprovalCard(props: PendingApprovalCardProps) { + const options = props.approval.options ?? DEFAULT_APPROVAL_OPTIONS; // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed // behind this card, so a translucent surface bleeds messages through it. return ( @@ -22,7 +33,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { Approval needed - {props.approval.requestKind} + {props.approval.appName ?? props.approval.requestKind} {props.approval.detail ? ( @@ -30,29 +41,32 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { ) : null} - void props.onRespond(props.approval.requestId, "accept")} - > - Allow once - - void props.onRespond(props.approval.requestId, "acceptForSession")} - > - - Allow session - - - void props.onRespond(props.approval.requestId, "decline")} - > - Decline - + {options.map((option) => ( + void props.onRespond(props.approval.requestId, option.decision)} + > + + {option.label} + + + ))} ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 06f0c49fec25..7b6fd82a435f 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -71,6 +71,7 @@ import { import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; import { type ExistingThreadSettingsRouteSession, useExistingThreadSettingsRoutePresentation, @@ -448,7 +449,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); } - return [...builtIn, ...providerCommands]; + 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]; } if (composerTrigger.kind === "skill") { @@ -570,7 +581,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); try { - await onSendMessage(); + const messageId = await onSendMessage(); + if (messageId === null) { + return; + } // Sending a prompt starts agent work: arm the lock-screen card while the // app is foregrounded and the activity token can be registered. Armed // after the send so its preference read and native Activity start don't diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index dac69f819294..49030911ef33 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -270,9 +270,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const listRef = useRef(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const selectedThreadKeyRef = useRef(selectedThreadKey); - const lastScrolledAnchorMessageIdRef = useRef(null); + const lastScrolledSubmittedMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); + const [submittedMessageId, setSubmittedMessageId] = useState(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); // Android keys the safe-area padding on keyboard visibility (#5988): the // back gesture closes the keyboard while the editor stays focused, and a @@ -471,7 +472,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useEffect(() => { setAnchorMessageId(null); - lastScrolledAnchorMessageIdRef.current = null; + setSubmittedMessageId(null); + lastScrolledSubmittedMessageIdRef.current = null; setEndFollowEnabled(true); freeze.set(false); }, [freeze, selectedThreadKey]); @@ -480,9 +482,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // Anchor as soon as the target row exists in the feed — including local // outbox "Sending" bubbles painted before thread detail has finished loading. if ( - anchorMessageId === null || - lastScrolledAnchorMessageIdRef.current === anchorMessageId || - !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) + submittedMessageId === null || + lastScrolledSubmittedMessageIdRef.current === submittedMessageId || + contentPresentationKind !== "ready" || + !selectedThreadFeed.some( + (entry) => entry.type === "message" && entry.id === submittedMessageId, + ) ) { return; } @@ -492,7 +497,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (selectedThreadKeyRef.current !== targetThreadKey) { return; } - lastScrolledAnchorMessageIdRef.current = anchorMessageId; + lastScrolledSubmittedMessageIdRef.current = submittedMessageId; // Wait for the keyboard dismissal (started by blur() on send) to finish // before scrolling: scrollMessageToEnd freezes keyboard-driven inset // updates while it runs, and a close event swallowed by that freeze @@ -502,7 +507,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread .then(() => { if ( selectedThreadKeyRef.current !== targetThreadKey || - lastScrolledAnchorMessageIdRef.current !== anchorMessageId + lastScrolledSubmittedMessageIdRef.current !== submittedMessageId ) { return; } @@ -511,16 +516,23 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread .catch(() => { if ( selectedThreadKeyRef.current !== targetThreadKey || - lastScrolledAnchorMessageIdRef.current !== anchorMessageId + lastScrolledSubmittedMessageIdRef.current !== submittedMessageId ) { return; } - lastScrolledAnchorMessageIdRef.current = null; + lastScrolledSubmittedMessageIdRef.current = null; freeze.set(false); }); }); return () => cancelAnimationFrame(frame); - }, [anchorMessageId, freeze, selectedThreadFeed, scrollMessageToEnd, selectedThreadKey]); + }, [ + submittedMessageId, + freeze, + contentPresentationKind, + selectedThreadFeed, + scrollMessageToEnd, + selectedThreadKey, + ]); const sendEntersQueue = props.sendEntersQueue; const handleSendMessage = useCallback(async () => { @@ -540,6 +552,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // applied. Enabling end maintenance alone is ineffective when the list // was scrolled into older history. listRef.current?.scrollToEnd({ animated: false }); + setSubmittedMessageId(messageId); setAnchorMessageId(messageId); } composerEditorRef.current?.blur(); @@ -619,6 +632,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} + submittedMessageId={submittedMessageId} contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} contentBottomInset={estimatedOverlayHeight} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f8b50469d376..41e4dafee001 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,7 +1,8 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; -import { MessageId, type EnvironmentId, type ThreadId, type TurnId } from "@t3tools/contracts"; +import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import { SymbolView } from "../../components/AppSymbol"; @@ -39,6 +40,7 @@ import { type ColorValue, useWindowDimensions, View, + type ViewStyle, } from "react-native"; import { TouchableOpacity } from "react-native-gesture-handler"; import ImageViewing from "react-native-image-viewing"; @@ -54,6 +56,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, + type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, } from "../../native/SelectableMarkdownText"; @@ -73,7 +76,11 @@ import { } from "../review/nativeReviewDiffAdapter"; import { buildReviewParsedDiff } from "../review/reviewModel"; import { cn } from "../../lib/cn"; -import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout"; +import { + deriveCenteredContentHorizontalPadding, + deriveThreadFeedInitialContentInset, + type LayoutVariant, +} from "../../lib/layout"; import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, @@ -101,8 +108,9 @@ import { WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; -import { useAssetUrl } from "../../state/assets"; +import { useAssetUrl, useAssetUrlState } from "../../state/assets"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; +import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", @@ -152,6 +160,7 @@ export interface ThreadFeedProps { readonly listRef: RefObject; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; + readonly submittedMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; readonly contentTopInset?: number; readonly contentBottomInset?: number; @@ -196,6 +205,165 @@ function MessageAttachmentImage(props: { ); } +function ThreadMarkdownImageView(props: { + readonly uri: string | null; + readonly sourceKey: string; + readonly unavailable: boolean; + readonly alt: string | null; + readonly onPressImage: (uri: string) => void; +}) { + const codeBackground = useThemeColor("--color-md-code-bg"); + const [availableWidth, setAvailableWidth] = useState(0); + const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); + const [failedUri, setFailedUri] = useState(null); + + useEffect(() => { + setSourceSize(null); + }, [props.sourceKey]); + + useEffect(() => { + setFailedUri(null); + }, [props.uri]); + + const displaySize = + sourceSize === null + ? null + : resolveMarkdownImageDisplaySize({ + sourceWidth: sourceSize.width, + sourceHeight: sourceSize.height, + availableWidth, + }); + const failed = props.unavailable || (props.uri !== null && failedUri === props.uri); + const placeholderWidth: ViewStyle["width"] = + availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%"; + const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 }; + + return ( + setAvailableWidth(event.nativeEvent.layout.width)} + style={{ alignSelf: "stretch", gap: 6 }} + > + {props.uri === null || failed ? ( + + {failed ? ( + Image unavailable + ) : ( + + )} + + ) : ( + props.onPressImage(props.uri!)} + style={{ alignSelf: "flex-start" }} + > + + setFailedUri(props.uri)} + /> + + + )} + {props.alt ? ( + + {props.alt} + + ) : null} + + ); +} + +function ThreadMarkdownImageRequest(props: { + readonly uri: string; + readonly onLoad: (sourceSize: { width: number; height: number }) => void; + readonly onError: () => void; +}) { + const [loaded, setLoaded] = useState(false); + + return ( + <> + { + setLoaded(true); + props.onLoad(event.nativeEvent.source); + }} + onError={props.onError} + style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} + /> + {loaded ? null : ( + + Loading image… + + )} + + ); +} + +/** Markdown image whose src is a workspace file — loads through a signed asset URL. */ +function ThreadMarkdownImage(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly path: string; + readonly alt: string | null; + readonly onPressImage: (uri: string) => void; +}) { + const assetUrl = useAssetUrlState(props.environmentId, { + _tag: "workspace-file", + threadId: props.threadId, + path: props.path, + }); + + return ( + + ); +} + +function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { + return ( + undefined} + /> + ); +} + const MARKDOWN_MONO_FONT = Platform.select({ ios: "ui-monospace", android: "monospace", @@ -411,7 +579,10 @@ function useReviewCommentColors(): ReviewCommentColors { ); } -function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { +function useMarkdownStyles( + onLinkPress: (href: string) => void, + renderImage: MarkdownImageRenderer, +): MarkdownStyleSets { const { appearance, themeAppearance } = useAppearancePreferences(); const markdownFontSizes = useMemo( () => resolveMarkdownFontSizes(appearance.baseFontSize), @@ -616,6 +787,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe })} ), + image: ({ node }) => + node.href + ? (renderImage({ + href: node.href, + alt: node.alt ?? null, + title: node.title ?? null, + }) ?? undefined) + : undefined, code_inline: ({ content }) => { const value = content ?? ""; return ( @@ -789,6 +968,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe nativeMarkdownTypography, onLinkPress, regularFontFamily, + renderImage, themeMode, userBubbleForegroundMuted, userBubbleSkillForeground, @@ -808,6 +988,7 @@ function renderFeedEntry( readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly onMarkdownLinkPress: (href: string) => void; + readonly renderMarkdownImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; @@ -907,6 +1088,7 @@ function renderFeedEntry( reviewCommentColors={props.reviewCommentColors} skills={props.skills} onLinkPress={props.onMarkdownLinkPress} + renderImage={props.renderMarkdownImage} /> ) : null} {attachments.map((attachment) => { @@ -966,6 +1148,7 @@ function renderFeedEntry( skills={props.skills} textStyle={styles.nativeTextStyle} onLinkPress={props.onMarkdownLinkPress} + renderImage={props.renderMarkdownImage} /> ) : ( ; readonly onLinkPress: (href: string) => void; + readonly renderImage: MarkdownImageRenderer; }) { const segments = parseReviewCommentMessageSegments(props.text); const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); @@ -1063,6 +1247,7 @@ function UserMessageContent(props: { textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks onLinkPress={props.onLinkPress} + renderImage={props.renderImage} /> ); } @@ -1104,6 +1289,7 @@ function UserMessageContent(props: { textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks onLinkPress={props.onLinkPress} + renderImage={props.renderImage} /> ) : ( ( + (image) => { + const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); + if (imageSource._tag === "Direct") { + return ( + setExpandedImage({ uri })} + /> + ); + } + if (imageSource._tag === "Blocked") { + return ; + } + return ( + setExpandedImage({ uri })} + /> + ); + }, + [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. // Keep row-local interaction props in extraData so disclosures and copy feedback repaint. @@ -1568,12 +1788,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { transitionEndFollow({ type: "reset" }); }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); useEffect(() => { - if (props.anchorMessageId !== null) { + if (props.submittedMessageId !== null) { clearUserScrollSettle(); userScrollSessionRef.current = false; transitionEndFollow({ type: "reset" }); } - }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); + }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]); // Mark unread only for activity that lands while follow is broken; a thread // switch re-arms follow above and clears the flag through setEndFollow. @@ -1622,17 +1842,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // 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. Without this, the remounted list's - // initial scroll-to-end computes with a zero end inset and rests one - // composer-height short of the end. + // 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. // - // Fork: the key goes filled once per thread open and stays there. Toggling - // it back on a feed that briefly empties during sync remounts mid-read, - // which looks like the conversation was cleared and reloaded. - // Keyed on the environment-scoped key, not the bare thread id: two - // environments can hold the same id, and latching on the id alone would - // carry "already filled" across the switch and skip the remount the new - // feed needs to pick up the composer inset. + // The key goes filled once per thread open and stays there. Toggling it + // back on a feed that briefly empties during sync remounts mid-read, which + // looks like the conversation was cleared and reloaded. Keyed on the + // environment-scoped key, not the bare thread id: two environments can hold + // the same id, and latching on the id alone would carry "already filled" + // across the switch and skip the remount the new feed needs. const listMountThreadKeyRef = useRef(feedThreadKey); const sawFilledFeedRef = useRef(props.feed.length > 0); if (listMountThreadKeyRef.current !== feedThreadKey) { @@ -1654,7 +1874,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { resolveChatListAnchoredEndSpace( presentedFeed, props.anchorMessageId, - (entry) => (entry.type === "message" ? entry.id : null), + (entry) => (entry.type === "message" && entry.message.role === "user" ? entry.id : null), { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, ), [presentedFeed, props.anchorMessageId, anchorTopInset], @@ -1858,6 +2078,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressImage, onMarkdownLinkPress, + renderMarkdownImage, iconSubtleColor, userBubbleColor, markdownStyles, @@ -1885,6 +2106,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkRow, props.environmentId, props.skills, + renderMarkdownImage, ], ); @@ -1945,6 +2167,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // ThreadDetailScreen); this tells LegendList's scroll math about the // extra so programmatic end scrolls land at the true resting offset. contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0} + // Android: the composer overlay only exists as the keyboard + // integration's animated bottom padding, which the list's scroll + // math cannot see until the inset reports above land — and those + // arrive via runOnJS, racing the remounted list's one-shot initial + // scroll-at-end. Seed the estimated overlay height as a declarative + // contentInset floor: LegendList consumes it in JS math only + // (Android's ScrollView has no native contentInset prop) and the + // first reported override REPLACES it instead of adding to it. + // Not on iOS: there the prop would reach UIKit and inset natively + // on top of the animated padding. + {...(initialContentInset ? { contentInset: initialContentInset } : {})} // The keyboard integration's offset math (end pinning, max scroll) // must add the same UIKit-added extra, or its keyboard-open end // targets land one safe-area short of the true resting offset. diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index a1bac9078292..150036c77abc 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,4 +1,3 @@ -import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -18,15 +17,13 @@ import { useAtomSet, 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, type ReactNode } from "react"; -import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { LayoutChangeEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { SearchBarCommands } from "react-native-screens"; -import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; @@ -44,13 +41,13 @@ import { import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadSearch } from "../../state/queries"; +import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { BoardScreen } from "../board/BoardScreen"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { DEFAULT_OWNERSHIP_FILTER, hasCustomHomeListOptions, @@ -108,6 +105,7 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, + type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "./threadListV2"; @@ -119,48 +117,7 @@ type SidebarListItem = | ThreadListV2ListItem | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; -/** - * Shared capsule behind the sidebar header buttons — a native liquid-glass - * surface on iOS 26+, a tinted pill everywhere else. - */ -function SidebarHeaderButtonGroup(props: { - readonly children: ReactNode; - readonly colorScheme: "light" | "dark"; -}) { - const fallbackBackground = useThemeColor("--color-glass-surface"); - const fallbackBorder = useThemeColor("--color-header-border"); - if (isLiquidGlassSupported) { - return ( - - {props.children} - - ); - } - - return ( - - {props.children} - - ); -} - const SIDEBAR_STICKY_HEADER_HEIGHT = 106; -const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44; -const SIDEBAR_HEADER_WASH_OPACITY = { - dark: [0.22, 0.14, 0.04], - light: [0.46, 0.3, 0.08], -} as const; interface ThreadNavigationSidebarProps { readonly width: number; @@ -217,16 +174,13 @@ function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, ) { const insets = useSafeAreaInsets(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); - const [headerIsOverContent, setHeaderIsOverContent] = useState(false); const searchInputRef = useRef(null); const searchBarRef = useRef(null); const openSwipeableRef = useRef(null); - const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); const { archiveThread, @@ -510,15 +464,16 @@ function ThreadNavigationSidebarPane( // 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 + ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => { + (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { setChangeRequestByKey((current) => { const existing = current.get(threadKey) ?? null; if ( (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) + (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && + (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) ) { return current; } @@ -548,10 +503,13 @@ function ThreadNavigationSidebarPane( () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); - const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); - const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); - const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const { + loaded: shelfPreferencesLoaded, + settledShelfExpanded, + snoozedShelfExpanded, + 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". @@ -1103,8 +1061,6 @@ function ThreadNavigationSidebarPane( const borderColor = useThemeColor("--color-border"); const mutedColor = useThemeColor("--color-foreground-muted"); const placeholderColor = useThemeColor("--color-placeholder"); - const headerFadeColor = String(backgroundColor); - const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; 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 @@ -1133,19 +1089,10 @@ function ThreadNavigationSidebarPane( }, [props.onSelectThread], ); - const handleScroll = useCallback((event: NativeSyntheticEvent) => { - const next = event.nativeEvent.contentOffset.y > 6; - if (headerIsOverContentRef.current === next) { - return; - } - headerIsOverContentRef.current = next; - setHeaderIsOverContent(next); - }, []); const handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); // Project shells load after the first rows draw, so the maps they feed have @@ -1342,6 +1289,7 @@ function ThreadNavigationSidebarPane( return ( - - - - - - - - - - - - - {/* Title slot doubles as the connection status surface: while an environment reconnects, the title fades to a status label in @@ -1828,21 +1754,16 @@ function ThreadNavigationSidebarPane( } /> - + - + - + {options.listMode === "board" ? null : ( @@ -1869,12 +1790,6 @@ function ThreadNavigationSidebarPane( } const styles = StyleSheet.create({ - headerButtonGroup: { - alignItems: "center", - borderRadius: 22, - flexDirection: "row", - overflow: "hidden", - }, threadList: { flex: 1, }, diff --git a/apps/mobile/src/features/threads/composerSlashSkillSearch.test.ts b/apps/mobile/src/features/threads/composerSlashSkillSearch.test.ts new file mode 100644 index 000000000000..5ac64626a97e --- /dev/null +++ b/apps/mobile/src/features/threads/composerSlashSkillSearch.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; + +const browserSkill = { + name: "browser", + path: "/skills/browser/SKILL.md", + enabled: true, + shortDescription: "Open and control the in-app browser", +}; + +describe("matchesSlashSkillQuery", () => { + it("matches the rendered skill prefix", () => { + expect(matchesSlashSkillQuery(browserSkill, "skill")).toBe(true); + expect(matchesSlashSkillQuery(browserSkill, "skill:brow")).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/composerSlashSkillSearch.ts b/apps/mobile/src/features/threads/composerSlashSkillSearch.ts new file mode 100644 index 000000000000..7cd81f036fdd --- /dev/null +++ b/apps/mobile/src/features/threads/composerSlashSkillSearch.ts @@ -0,0 +1,16 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; + +export function matchesSlashSkillQuery(skill: ServerProviderSkill, query: string): boolean { + if (!skill.enabled) return false; + const normalizedQuery = query.toLowerCase(); + const skillQuery = + normalizedQuery === "skill" + ? "" + : normalizedQuery.startsWith("skill:") + ? normalizedQuery.slice("skill:".length) + : normalizedQuery; + if (!skillQuery) return true; + return [skill.name, skill.displayName, skill.shortDescription, skill.description].some((value) => + value?.toLowerCase().includes(skillQuery), + ); +} diff --git a/apps/mobile/src/features/threads/markdownImageSize.test.ts b/apps/mobile/src/features/threads/markdownImageSize.test.ts new file mode 100644 index 000000000000..76170890d519 --- /dev/null +++ b/apps/mobile/src/features/threads/markdownImageSize.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + MARKDOWN_IMAGE_MAX_HEIGHT, + MARKDOWN_IMAGE_MAX_WIDTH, + resolveMarkdownImageDisplaySize, +} from "./markdownImageSize"; + +describe("resolveMarkdownImageDisplaySize", () => { + it("keeps small images at their intrinsic size", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 96, + sourceHeight: 96, + availableWidth: 332, + }), + ).toEqual({ width: 96, height: 96 }); + }); + + it("fits wide images to the available chat width", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 960, + sourceHeight: 540, + availableWidth: 332, + }), + ).toEqual({ width: 332, height: 186.75 }); + }); + + it("caps wide images at 480 points on larger screens", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 960, + sourceHeight: 540, + availableWidth: 900, + }), + ).toEqual({ width: MARKDOWN_IMAGE_MAX_WIDTH, height: 270 }); + }); + + it("caps tall images by height without changing their aspect ratio", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 400, + sourceHeight: 800, + availableWidth: 332, + }), + ).toEqual({ width: 240, height: MARKDOWN_IMAGE_MAX_HEIGHT }); + }); + + it("rejects dimensions that cannot produce a stable layout", () => { + expect( + resolveMarkdownImageDisplaySize({ sourceWidth: 0, sourceHeight: 100, availableWidth: 332 }), + ).toBeNull(); + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 100, + sourceHeight: Number.NaN, + availableWidth: 332, + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/markdownImageSize.ts b/apps/mobile/src/features/threads/markdownImageSize.ts new file mode 100644 index 000000000000..0fb6f8fbcc6d --- /dev/null +++ b/apps/mobile/src/features/threads/markdownImageSize.ts @@ -0,0 +1,37 @@ +export const MARKDOWN_IMAGE_MAX_WIDTH = 480; +export const MARKDOWN_IMAGE_MAX_HEIGHT = 480; + +export interface MarkdownImageDisplaySize { + readonly width: number; + readonly height: number; +} + +/** Keeps small images intrinsic while fitting larger images inside the chat viewport. */ +export function resolveMarkdownImageDisplaySize(input: { + readonly sourceWidth: number; + readonly sourceHeight: number; + readonly availableWidth: number; +}): MarkdownImageDisplaySize | null { + if ( + !Number.isFinite(input.sourceWidth) || + !Number.isFinite(input.sourceHeight) || + !Number.isFinite(input.availableWidth) || + input.sourceWidth <= 0 || + input.sourceHeight <= 0 || + input.availableWidth <= 0 + ) { + return null; + } + + const scale = Math.min( + 1, + input.availableWidth / input.sourceWidth, + MARKDOWN_IMAGE_MAX_WIDTH / input.sourceWidth, + MARKDOWN_IMAGE_MAX_HEIGHT / input.sourceHeight, + ); + + return { + width: input.sourceWidth * scale, + height: input.sourceHeight * scale, + }; +} diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx index 0c33da436a57..1895ef0d45ca 100644 --- a/apps/mobile/src/features/threads/sidebar-filter-button.tsx +++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx @@ -1,5 +1,5 @@ import { SymbolView } from "../../components/AppSymbol"; -import { Pressable, StyleSheet } from "react-native"; +import { Pressable } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -10,31 +10,17 @@ export type SidebarFilterButtonIcon = export function SidebarFilterButton(props: { readonly accessibilityLabel: string; readonly icon: SidebarFilterButtonIcon; - /** Rendered inside a shared capsule group — no own background/border. */ - readonly grouped?: boolean; }) { const iconColor = useThemeColor("--color-foreground"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); - const idleBackgroundColor = useThemeColor("--color-glass-surface"); - const borderColor = useThemeColor("--color-header-border"); return ( [ - props.grouped - ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } - : { - backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, - borderColor, - borderWidth: StyleSheet.hairlineWidth, - }, - ]} > - + ); } diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.tsx index f5c91a26d4b4..5f5a8d51c0f4 100644 --- a/apps/mobile/src/features/threads/sidebar-header-actions.tsx +++ b/apps/mobile/src/features/threads/sidebar-header-actions.tsx @@ -1,5 +1,5 @@ import { SymbolView } from "../../components/AppSymbol"; -import { Pressable, StyleSheet, View } from "react-native"; +import { Pressable, View } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; import { @@ -13,39 +13,24 @@ export interface SidebarHeaderActionsProps { readonly onOpenSettings: () => void; readonly listMode: HomeListMode; readonly onListModeChange: (mode: HomeListMode) => void; - /** Rendered inside a shared capsule group — buttons drop their own chrome. */ - readonly grouped?: boolean; } function FallbackHeaderButton(props: { readonly accessibilityLabel: string; readonly icon: string; - readonly grouped?: boolean; readonly onPress: () => void; }) { const iconColor = useThemeColor("--color-foreground"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); - const idleBackgroundColor = useThemeColor("--color-glass-surface"); - const borderColor = useThemeColor("--color-header-border"); return ( [ - props.grouped - ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } - : { - backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, - borderColor, - borderWidth: StyleSheet.hairlineWidth, - }, - ]} > - + ); } @@ -58,14 +43,12 @@ export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { props.onListModeChange(mode)} /> ))} diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 8cc68cb3c525..2ea207923429 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -1,6 +1,71 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveThreadFeedLiveFollow } from "./thread-feed-live-follow"; +import { + resolveThreadFeedLiveFollow, + resolveThreadFeedSubmissionAnchor, +} from "./thread-feed-live-follow"; + +describe("resolveThreadFeedSubmissionAnchor", () => { + it("anchors the first user message in a thread", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: null, + submittedMessageId: "first-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBe("first-message"); + }); + + it("preserves the first-message anchor when another message is queued", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 1, + }), + ).toBe("first-message"); + }); + + it("preserves the first-message anchor after its outbox entry drains", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBe("first-message"); + }); + + it("does not anchor a follow-up after a user message appears", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: true, + queuedMessageCount: 0, + }), + ).toBeNull(); + }); + + it("does not anchor a thread that has already started a turn", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: null, + submittedMessageId: "second-message", + hasStartedTurn: true, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBeNull(); + }); +}); describe("resolveThreadFeedLiveFollow", () => { it("pauses immediately when the user starts scrolling", () => { diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index babe18f0c1cb..312fd67473e5 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -12,6 +12,24 @@ export type ThreadFeedLiveFollowEvent = readonly userScrollSessionActive: boolean; }; +export function resolveThreadFeedSubmissionAnchor(input: { + readonly currentAnchorMessageId: AnchorId | null; + readonly submittedMessageId: AnchorId; + readonly hasStartedTurn: boolean; + readonly hasUserMessage: boolean; + readonly queuedMessageCount: number; +}): AnchorId | null { + if (input.hasStartedTurn || input.hasUserMessage) { + return null; + } + + if (input.currentAnchorMessageId !== null) { + return input.currentAnchorMessageId; + } + + return input.queuedMessageCount > 0 ? null : input.submittedMessageId; +} + export function resolveThreadFeedLiveFollow( current: boolean, event: ThreadFeedLiveFollowEvent, 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 d2a8aed314bd..e18b80dc58a3 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"; @@ -31,10 +27,12 @@ import { ThreadIdentityMark } from "../identity/ParticipantStack"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { + resolveThreadListV2ChangeRequestState, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, + type ThreadListV2ChangeRequestState, type ThreadListV2Status, } from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -119,6 +117,7 @@ const SNOOZE_ACCENT_DARK = "#60a5fa"; export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { readonly count: number; + readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; @@ -131,11 +130,12 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS } accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`} accessibilityRole="button" - accessibilityState={{ expanded: props.expanded }} + accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} + disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > @@ -156,6 +156,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: { readonly count: number; + readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; @@ -168,11 +169,12 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS } accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`} accessibilityRole="button" - accessibilityState={{ expanded: props.expanded }} + accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} + disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > @@ -210,6 +212,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props readonly pane?: "screen" | "sidebar"; /** Draws the "Pending" divider above the first queued row. */ readonly showPendingDivider: boolean; + /** Keeps row hairlines inside a section; section headers draw their own rule. */ + readonly showTrailingDivider?: boolean; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { @@ -298,7 +302,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props ) : ( {rowContent} - + {props.showTrailingDivider !== false ? ( + + ) : null} )} @@ -334,6 +340,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { into the drawer surface, selection filled with the accent color — matching the v1 sidebar rows. */ readonly pane?: "screen" | "sidebar"; + /** Keeps row hairlines inside a section; section headers draw their own rule. */ + readonly showTrailingDivider?: boolean; /** Highlights the thread open in the detail pane (iPad split view). The compact Home list never sets it — phones navigate away on select. */ readonly selected?: boolean; @@ -372,7 +380,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { merge and close rules. Mirrors web's onChangeRequestState. */ readonly onChangeRequestState?: ( threadKey: string, - changeRequest: ChangeRequestSettleSource | null, + changeRequest: ThreadListV2ChangeRequestState | null, ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; @@ -406,11 +414,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const prUpdatedAt = pr?.updatedAt ?? null; const threadKey = scopedThreadKey(thread.environmentId, thread.id); useEffect(() => { - onChangeRequestState?.( - threadKey, - prState === null ? null : { state: prState, updatedAt: prUpdatedAt }, - ); - }, [onChangeRequestState, prState, prUpdatedAt, threadKey]); + const changeRequest = resolveThreadListV2ChangeRequestState({ + linkedPullRequest: thread.linkedPullRequest, + state: prState, + updatedAt: prUpdatedAt, + }); + if (changeRequest === undefined) return; + onChangeRequestState?.(threadKey, changeRequest); + }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]); const composerDrafts = useAtomValue(composerDraftsAtom); const hasDraft = hasComposerDraftMessage(composerDrafts[threadKey]); @@ -507,7 +518,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } satisfies MenuAction, ] : []), - pinnedRow + thread.pinnedAt != null ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] @@ -518,6 +529,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { props.canMovePinnedUp, props.pinReorderSupported, props.pinningSupported, + thread.pinnedAt, ], ); const titleRegenerationMenuItems = useMemo( @@ -553,8 +565,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [pinMenuItem, titleRegenerationMenuItems], ); const slimMenuActions = useMemo( - () => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + SLIM_MENU_ACTIONS[0]!, + ...(thread.pinnedAt != null ? pinMenuItem : []), + ...titleRegenerationMenuItems, + SLIM_MENU_ACTIONS[1]!, + ], + [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], @@ -839,7 +856,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { actions reveal behind the row. */ {cardContent} - + {props.showTrailingDivider !== false ? ( + + ) : null} )} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 8392ccc86181..e2f611d94e73 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, + resolveThreadListV2ChangeRequestState, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -53,6 +54,48 @@ 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("resolveThreadListV2ChangeRequestState", () => { + it("preserves the previous state while a linked pull request reloads", () => { + expect( + resolveThreadListV2ChangeRequestState({ + linkedPullRequest, + state: null, + updatedAt: null, + }), + ).toBeUndefined(); + }); + + it("clears the previous state after a pull request is unlinked", () => { + expect( + resolveThreadListV2ChangeRequestState({ + linkedPullRequest: null, + state: null, + updatedAt: null, + }), + ).toBeNull(); + }); + + it("reports a loaded linked pull request", () => { + expect( + resolveThreadListV2ChangeRequestState({ + linkedPullRequest, + state: "merged", + updatedAt: "2026-06-02T00:00:00.000Z", + }), + ).toEqual({ + state: "merged", + updatedAt: "2026-06-02T00:00:00.000Z", + linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', + }); + }); +}); describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { @@ -302,6 +345,58 @@ describe("buildThreadListV2Items", () => { ); }); + it("ignores the previous pull request state after a different pull request is linked", () => { + const thread = makeThread({ + id: ThreadId.make("linked"), + title: "Linked pull request", + linkedPullRequest, + }); + const layout = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([ + [ + `${environmentId}:${thread.id}`, + { + state: "merged" as const, + linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]', + }, + ], + ]), + now: NOW, + }); + + expect(layout.settledCount).toBe(0); + expect(layout.items[0]?.variant).toBe("card"); + }); + + it("settles a thread only when the cached pull request identity matches", () => { + const thread = makeThread({ + id: ThreadId.make("linked-merged"), + title: "Linked merged pull request", + linkedPullRequest, + }); + const layout = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([ + [ + `${environmentId}:${thread.id}`, + { + state: "merged" as const, + linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', + }, + ], + ]), + now: NOW, + }); + + expect(layout.settledCount).toBe(1); + expect(layout.items[0]?.variant).toBe("slim"); + }); + it("keeps a merged thread active when auto-settle on merge is off", () => { const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); const layout = buildThreadListV2Items({ @@ -348,7 +443,7 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(1); }); - it("renders pinned threads first and exempts them from auto-settle — parity with web", () => { + it("places settled pinned threads in the settled shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), @@ -356,7 +451,6 @@ describe("buildThreadListV2Items", () => { id: ThreadId.make("pinned-settled"), title: "Pinned while settled", pinnedAt: "2026-06-01T12:00:00.000Z", - // Stale settled state (the decider clears it on pin): the pin wins. settledOverride: "settled", settledAt: "2026-06-01T12:00:00.000Z", }), @@ -366,8 +460,81 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]); - expect(layout.items.map((item) => item.pinned)).toEqual([true, false]); + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]); + expect(layout.items.map((item) => item.pinned)).toEqual([false, false]); + 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", + pinnedAt: "2026-06-01T12:00:00.000Z", + }); + const layout = buildThreadListV2Items({ + threads: [merged], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), + autoSettleOnMerge: false, + now: NOW, + }); + + expect(layout.items[0]).toMatchObject({ + thread: { id: "pinned-merged" }, + variant: "card", + pinned: true, + }); expect(layout.settledCount).toBe(0); }); @@ -871,7 +1038,7 @@ describe("buildThreadListV2ListItems", () => { ]); }); - it("adds recency headers to active and settled sections while pins stay above them", () => { + it("adds recency headers to active and settled sections; inactive pins sit in settled", () => { const now = "2026-06-02T12:00:00.000Z"; const groupedLayout = buildThreadListV2Items({ threads: [ @@ -921,7 +1088,6 @@ describe("buildThreadListV2ListItems", () => { }); expect(items.map((item) => item.key)).toEqual([ - `v2-thread:${environmentId}:pinned`, "v2-recency-header:active:last_hour", `v2-thread:${environmentId}:active-recent`, "v2-recency-header:active:yesterday", @@ -931,6 +1097,8 @@ describe("buildThreadListV2ListItems", () => { `v2-thread:${environmentId}:settled-recent`, "v2-recency-header:settled:yesterday", `v2-thread:${environmentId}:settled-yesterday`, + "v2-recency-header:settled:previous_30_days", + `v2-thread:${environmentId}:pinned`, ]); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 47b412cc3dfa..77cc1ce8f2fd 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -17,7 +17,7 @@ import { shouldShowRecencySectionHeaders, } from "@t3tools/client-runtime/state/thread-recency-groups"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; import { threadMatchesAttributeQuery } from "@t3tools/shared/threadAttributeSearch"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -35,6 +35,35 @@ export { snoozeWakeLabel }; export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; +export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource { + readonly linkedPullRequestKey?: string | null; +} + +function linkedPullRequestKey( + linkedPullRequest: ThreadLinkedPullRequest | null | undefined, +): string | null { + if (linkedPullRequest == null) return null; + return JSON.stringify([ + linkedPullRequest.projectId, + linkedPullRequest.repository.toLowerCase(), + linkedPullRequest.number, + ]); +} + +/** Keep the previous linked PR state while its detail query reloads. */ +export function resolveThreadListV2ChangeRequestState(input: { + readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined; + readonly state: ChangeRequestSettleSource["state"] | null; + readonly updatedAt: string | null; +}): ThreadListV2ChangeRequestState | null | undefined { + if (input.state === null) return input.linkedPullRequest == null ? null : undefined; + return { + state: input.state, + updatedAt: input.updatedAt, + linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest), + }; +} + export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; @@ -385,7 +414,7 @@ export function buildThreadListV2Items(input: { readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; /** Per-row PR reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestByKey?: ReadonlyMap; + 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). */ @@ -473,12 +502,15 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; - const changeRequest = + const cachedChangeRequest = input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - // Visibility parity with web: snooze outranks everything, including a - // pin — a snoozed thread leaves the list until it wakes (or raises its - // hand). The pin (and its pinOrderKey) survives underneath, so a woken - // thread reappears at its exact spot in the pinned block. + const changeRequest = + cachedChangeRequest !== null && + (cachedChangeRequest.linkedPullRequestKey ?? null) === + linkedPullRequestKey(thread.linkedPullRequest) + ? cachedChangeRequest + : null; + // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -490,12 +522,6 @@ export function buildThreadListV2Items(input: { } continue; } - // A pin otherwise overrides the lifecycle: pinned threads render above - // the inbox and never auto-settle out of sight. - if (thread.pinnedAt != null) { - pinned.push(thread); - continue; - } if ( supportsSettlement && effectiveSettled(thread, { @@ -506,6 +532,8 @@ export function buildThreadListV2Items(input: { }) ) { settled.push(thread); + } else if (thread.pinnedAt != null) { + pinned.push(thread); } else { active.push(thread); } diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts new file mode 100644 index 000000000000..d45993364721 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts @@ -0,0 +1,45 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback, useRef } from "react"; + +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; + +/** + * Shared persisted shelf state for the compact Home list and iPad sidebar. + * Refs advance before persistence starts so consecutive presses always toggle + * the latest value, even if React has not rendered the optimistic patch yet. + */ +export function useThreadListV2ShelfPreferences() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferencesResult); + const snoozedShelfExpanded = + loaded && preferencesResult.value.threadListV2SnoozedShelfExpanded === true; + const settledShelfExpanded = + !loaded || preferencesResult.value.threadListV2SettledShelfExpanded !== false; + const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); + const settledShelfExpandedRef = useRef(settledShelfExpanded); + snoozedShelfExpandedRef.current = snoozedShelfExpanded; + settledShelfExpandedRef.current = settledShelfExpanded; + + const toggleSnoozedShelf = useCallback(() => { + if (!loaded) return; + const expanded = !snoozedShelfExpandedRef.current; + snoozedShelfExpandedRef.current = expanded; + savePreferences({ threadListV2SnoozedShelfExpanded: expanded }); + }, [loaded, savePreferences]); + const toggleSettledShelf = useCallback(() => { + if (!loaded) return; + const expanded = !settledShelfExpandedRef.current; + settledShelfExpandedRef.current = expanded; + savePreferences({ threadListV2SettledShelfExpanded: expanded }); + }, [loaded, savePreferences]); + + return { + loaded, + settledShelfExpanded, + snoozedShelfExpanded, + toggleSettledShelf, + toggleSnoozedShelf, + } as const; +} diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index 09897b6186e1..992beed3abe4 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -1,10 +1,18 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; +import * as Device from "expo-device"; import { Platform } from "react-native"; -export function authClientMetadata(): AuthClientPresentationMetadata { +export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { + const osMajorVersion = Number.parseInt(Device.osVersion?.split(".")[0] ?? "", 10); + const deviceModel = Device.modelName?.trim(); + return { label: "T3 Code Mobile", deviceType: "mobile", ...(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/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index f1f30b298b66..f474c7e6ea4f 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -1,12 +1,18 @@ -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(() => ({ + osVersion: "18.4.1", + modelName: "iPhone 15 Pro", +})); vi.mock("./runtime", () => ({ runtime: { @@ -15,17 +21,45 @@ 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.osVersion = "18.4.1"; + mobileDevice.modelName = "iPhone 15 Pro"; + }); + it("identifies mobile token exchanges for authorized-client presentation", () => { expect(authClientMetadata()).toEqual({ label: "T3 Code Mobile", deviceType: "mobile", os: "iOS", + osMajorVersion: 18, + deviceModel: "iPhone 15 Pro", + surface: "mobile", + }); + }); + + it("includes only the Android major version and hardware model", () => { + mobilePlatform.OS = "android"; + mobileDevice.osVersion = "15.2.1"; + mobileDevice.modelName = "Pixel 9"; + + expect(authClientMetadata()).toMatchObject({ + os: "Android", + osMajorVersion: 15, + deviceModel: "Pixel 9", + }); + }); + + it("includes the mobile app version when the client provides it", () => { + expect(authClientMetadata("1.2.3")).toMatchObject({ + surface: "mobile", + appVersion: "1.2.3", }); }); 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/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 6dea0beafbec..b1722a137c81 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -7,11 +7,34 @@ import { deriveFileInspectorPaneLayout, deriveLayout, deriveStableFormSheetDetent, + deriveThreadFeedInitialContentInset, deriveWorkspacePaneLayout, SPLIT_LAYOUT_MIN_HEIGHT, SPLIT_LAYOUT_MIN_WIDTH, } from "./layout"; +describe("deriveThreadFeedInitialContentInset", () => { + it("seeds Android scroll math with the composer overlay estimate", () => { + expect( + deriveThreadFeedInitialContentInset({ + platform: "android", + usesNativeAutomaticInsets: false, + bottomContentInset: 174, + }), + ).toEqual({ bottom: 174 }); + }); + + it("does not double native iOS insets", () => { + expect( + deriveThreadFeedInitialContentInset({ + platform: "ios", + usesNativeAutomaticInsets: true, + bottomContentInset: 174, + }), + ).toBeUndefined(); + }); +}); + describe("resizable pane constraints", () => { it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => { expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index eb0c45e0607d..33438a324c12 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -52,6 +52,18 @@ export interface FileInspectorPaneLayout { readonly width: number | null; } +export function deriveThreadFeedInitialContentInset(input: { + readonly platform: string; + readonly usesNativeAutomaticInsets: boolean; + readonly bottomContentInset: number; +}): { readonly bottom: number } | undefined { + if (input.platform !== "android" || input.usesNativeAutomaticInsets) { + return undefined; + } + + return { bottom: Math.max(0, input.bottomContentInset) }; +} + export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { 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/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 7b94dc629154..fe022c1191ae 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -207,6 +207,40 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); + it("persists Thread List v2 shelf expansion preferences", async () => { + await expect( + savePreferencesPatch({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }), + ).resolves.toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + + await expect(loadPreferences()).resolves.toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + }); + + it("ignores invalid Thread List v2 shelf expansion preference types", async () => { + mocks.setPreferencesJson( + JSON.stringify({ + baseFontSize: 17, + threadListV2SettledShelfExpanded: "false", + threadListV2SnoozedShelfExpanded: 1, + }), + 10, + ); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); + }); + it("reconciles fallback preferences after SQLite recovers", async () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10); await mocks.setItemAsync( diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 6b111981b68f..9c5dbbd0bef7 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; import { EventId, @@ -14,9 +15,10 @@ import { import { buildPendingUserInputAnswers, buildThreadFeed, + derivePendingApprovals, deriveQueuedMessageControls, - promoteSteeredQueuedMessages, deriveThreadFeedPresentation, + promoteSteeredQueuedMessages, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, @@ -46,6 +48,35 @@ describe("deriveQueuedMessageControls", () => { }); }); }); + +describe("Codex feedback pseudo-messages", () => { + it("keeps pending and completed feedback messages in the mobile thread body", () => { + const pending = { + id: MessageId.make("feedback-command"), + command: "/feedback The agent stopped early.", + createdAt: "2026-08-23T00:00:00.000Z", + status: "uploading" as const, + }; + const entries = [codexFeedbackMessage(pending), codexFeedbackMessage(pending, "assistant")].map( + (message) => ({ + type: "message" as const, + id: message.id, + createdAt: message.createdAt, + message, + }), + ); + + expect(deriveThreadFeedPresentation(entries, null, new Set())).toEqual(entries); + expect(entries[1]?.message.text).toBe("Sending feedback to OpenAI..."); + + const completed = codexFeedbackMessage( + { ...pending, status: "sent", feedbackId: "codex-thread-1" }, + "assistant", + ); + expect(completed.text).toContain("codex-thread-1"); + }); +}); + const singleSelectQuestion = { id: "runtime", header: "Runtime", @@ -137,6 +168,59 @@ describe("pending user input answers", () => { }); }); +describe("pending approvals", () => { + it("keeps app access approvals and persistence choices from remote environments", () => { + const options = [ + { decision: "decline", label: "Decline" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ]; + const activity = makeActivity({ + id: EventId.make("approval-safari"), + kind: "approval.requested", + summary: "App access approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { + requestId: "req-safari", + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + }); + + expect(derivePendingApprovals([activity])).toEqual([ + { + requestId: "req-safari", + requestKind: "mcp-elicitation", + createdAt: "2026-08-24T00:00:00.000Z", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + ]); + }); + + it("removes an app access approval after a remote client rejects it", () => { + const requested = makeActivity({ + id: EventId.make("approval-safari-open"), + kind: "approval.requested", + summary: "App access approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { requestId: "req-safari", requestKind: "mcp-elicitation" }, + }); + const resolved = makeActivity({ + id: EventId.make("approval-safari-resolved"), + kind: "approval.resolved", + summary: "Approval resolved", + createdAt: "2026-08-24T00:00:01.000Z", + payload: { requestId: "req-safari", decision: "decline" }, + }); + + expect(derivePendingApprovals([requested, resolved])).toEqual([]); + }); +}); + function makeActivity( input: Partial & Pick, @@ -211,6 +295,44 @@ describe("buildThreadFeed", () => { expect(resolved?.getFullDetail()).toContain("What is the goal?\nMake it sleep"); }); + it("keeps older local feedback before newer messages returned by the server", () => { + const submission = { + id: MessageId.make("feedback-command-ordering"), + command: "/feedback The agent stopped early.", + createdAt: "2026-08-23T00:00:01.000Z", + status: "sent" as const, + feedbackId: "codex-thread-1", + }; + const laterMessage = { + id: MessageId.make("later-server-message"), + role: "assistant" as const, + text: "Newer server response", + turnId: null, + createdAt: "2026-08-23T00:00:02.000Z", + updatedAt: "2026-08-23T00:00:02.000Z", + streaming: false, + }; + const thread = makeThread({ + id: ThreadId.make("thread-feedback-ordering"), + projectId: ProjectId.make("project-1"), + title: "Feedback ordering", + messages: [laterMessage], + }); + + const feed = buildThreadFeed(thread, { + localMessages: [ + codexFeedbackMessage(submission), + codexFeedbackMessage(submission, "assistant"), + ], + }); + + expect(feed.map((entry) => entry.id)).toEqual([ + "feedback-command-ordering", + "feedback-command-ordering:feedback", + "later-server-message", + ]); + }); + it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), @@ -431,7 +553,7 @@ describe("buildThreadFeed", () => { expect(serializedToolOutputs).toBe(1); }); - it("folds settled turn work while leaving the terminal answer visible", () => { + it("keeps the first and terminal assistant messages visible around settled work", () => { const turnId = TurnId.make("turn-1"); const thread = makeThread({ id: ThreadId.make("thread-3"), @@ -447,9 +569,9 @@ describe("buildThreadFeed", () => { }, messages: [ { - id: MessageId.make("assistant-commentary"), + id: MessageId.make("assistant-first"), role: "assistant", - text: "I am checking.", + text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.", turnId, streaming: false, createdAt: "2026-04-01T00:00:02.000Z", @@ -484,8 +606,12 @@ describe("buildThreadFeed", () => { const feed = buildThreadFeed(thread); const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); - expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); - expect(collapsed[0]).toMatchObject({ + expect(collapsed.map((entry) => entry.id)).toEqual([ + "assistant-first", + "turn-fold:turn-1", + "assistant-final", + ]); + expect(collapsed[1]).toMatchObject({ type: "turn-fold", label: "Worked for 17s", expanded: false, @@ -493,8 +619,8 @@ describe("buildThreadFeed", () => { const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); expect(expanded.map((entry) => entry.id)).toEqual([ + "assistant-first", "turn-fold:turn-1", - "assistant-commentary", "tool-completed", "assistant-final", ]); @@ -586,14 +712,15 @@ describe("buildThreadFeed", () => { const feed = buildThreadFeed(thread); const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); const ids = collapsed.map((entry) => entry.id); - // Real final stays outside the fold; status + tools collapse under it. + // Rehome keeps the mis-stamped final on turn-1; first+terminal fold keeps + // the first assistant visible (as the `::pre` preamble split) and hides + // tools in between. + expect(ids).toContain("assistant-status::pre"); expect(ids).toContain("assistant-final-misstamped"); expect(ids).toContain("turn-fold:turn-1"); expect(ids.indexOf("assistant-final-misstamped")).toBeGreaterThan( ids.indexOf("turn-fold:turn-1"), ); - // Status line is folded away until expanded. - expect(ids).not.toContain("assistant-status"); expect(ids).not.toContain("tool-1"); const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([firstTurnId])); @@ -606,6 +733,61 @@ describe("buildThreadFeed", () => { expect(expandedIds).toContain("assistant-next-final"); }); + it("folds assistant messages between the first and terminal messages", () => { + const turnId = TurnId.make("turn-1"); + const thread = makeThread({ + id: ThreadId.make("thread-middle-message"), + projectId: ProjectId.make("project-1"), + title: "Bounded narration", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:06.000Z", + assistantMessageId: MessageId.make("assistant-final"), + }, + messages: [ + { + id: MessageId.make("assistant-first"), + role: "assistant", + text: "The main result is ready.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:01.000Z", + updatedAt: "2026-04-01T00:00:02.000Z", + }, + { + id: MessageId.make("assistant-middle"), + role: "assistant", + text: "I am checking one more detail.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:03.000Z", + updatedAt: "2026-04-01T00:00:04.000Z", + }, + { + id: MessageId.make("assistant-final"), + role: "assistant", + text: "Verification finished.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:05.000Z", + updatedAt: "2026-04-01T00:00:06.000Z", + }, + ], + }); + + const feed = buildThreadFeed(thread); + const rows = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + + expect(rows.map((entry) => entry.id)).toEqual([ + "assistant-first", + "turn-fold:turn-1", + "assistant-final", + ]); + }); + it("measures a steer-superseded turn from its user boundary through trailing work", () => { const firstTurnId = TurnId.make("turn-1"); const secondTurnId = TurnId.make("turn-2"); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fd73ea599d38..c9cc95e5476c 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,4 +1,9 @@ -import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; +import { + ApprovalRequestId, + isToolLifecycleItemType, + ProviderApprovalOption, + ProviderRequestKind, +} from "@t3tools/contracts"; import type { MessageId, OrchestrationLatestTurn, @@ -18,16 +23,22 @@ import { deriveResolvedUserInputTranscripts } from "@t3tools/shared/userInputTra import * as Arr from "effect/Array"; import * as Order from "effect/Order"; +import * as Schema from "effect/Schema"; import type { DraftComposerImageAttachment } from "./composerImages"; export interface PendingApproval { readonly requestId: ApprovalRequestId; - readonly requestKind: "command" | "file-read" | "file-change"; + readonly requestKind: ProviderRequestKind; readonly createdAt: string; readonly detail?: string; + readonly appName?: string; + readonly options?: ReadonlyArray; } +const isProviderRequestKind = Schema.is(ProviderRequestKind); +const isProviderApprovalOption = Schema.is(ProviderApprovalOption); + export interface PendingUserInput { readonly requestId: ApprovalRequestId; readonly createdAt: string; @@ -214,6 +225,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "mcp_elicitation_approval": + return "mcp-elicitation"; default: return null; } @@ -1395,17 +1408,26 @@ function deriveThreadFeedTurnFolds( continue; } + const firstAssistantMessageId = entries.find( + (entry) => entry.type === "message" && entry.message.role === "assistant", + )?.id; const terminalAssistantMessageId = resolveTurnTerminalEntryId(turnId, entries, latestTurn); const hiddenEntryIds = new Set( - entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id), + entries + .filter( + (entry) => + entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId, + ) + .map((entry) => entry.id), ); if (hiddenEntryIds.size === 0) { continue; } const firstEntry = entries[0]; + const firstHiddenEntry = entries.find((entry) => hiddenEntryIds.has(entry.id)); const lastEntry = entries.at(-1); - if (!firstEntry || !lastEntry) { + if (!firstEntry || !firstHiddenEntry || !lastEntry) { continue; } const terminalEntry = terminalAssistantMessageId @@ -1434,9 +1456,9 @@ function deriveThreadFeedTurnFolds( ? `Worked for ${duration}` : "Worked"; - foldsByAnchorId.set(firstEntry.id, { + foldsByAnchorId.set(firstHiddenEntry.id, { turnId, - createdAt: firstEntry.createdAt, + createdAt: firstHiddenEntry.createdAt, hiddenEntryIds, label, }); @@ -1565,13 +1587,14 @@ export function derivePendingApprovals( ? (activity.payload as Record) : null; const requestId = parseApprovalRequestId(payload?.requestId); - const requestKind = - payload?.requestKind === "command" || - payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" - ? payload.requestKind - : requestKindFromRequestType(payload?.requestType); + const requestKind = isProviderRequestKind(payload?.requestKind) + ? payload.requestKind + : requestKindFromRequestType(payload?.requestType); const detail = typeof payload?.detail === "string" ? payload.detail : undefined; + const appName = typeof payload?.appName === "string" ? payload.appName : undefined; + const options = Array.isArray(payload?.options) + ? payload.options.filter(isProviderApprovalOption) + : undefined; if (activity.kind === "approval.requested" && requestId && requestKind) { openByRequestId.set(requestId, { @@ -1579,6 +1602,8 @@ export function derivePendingApprovals( requestKind, createdAt: activity.createdAt, ...(detail ? { detail } : {}), + ...(appName ? { appName } : {}), + ...(options && options.length > 0 ? { options } : {}), }); continue; } @@ -1716,14 +1741,18 @@ export function buildThreadFeed( thread: OrchestrationThread, options?: { readonly loadedMessages?: ReadonlyArray; + readonly localMessages?: ReadonlyArray; }, ): ThreadFeedEntry[] { const loadedMessages = options?.loadedMessages ?? thread.messages; + const messages = options?.localMessages + ? [...loadedMessages, ...options.localMessages] + : loadedMessages; const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; const workLogEntries = deriveWorkLogEntries(thread.activities); const rawEntries: Array = [ - ...loadedMessages.map((message) => ({ + ...messages.map((message) => ({ type: "message" as const, id: message.id, createdAt: message.createdAt, diff --git a/apps/mobile/src/mobileSurfaceExistence.test.ts b/apps/mobile/src/mobileSurfaceExistence.test.ts index 9fb44da63a5b..674561f6eafc 100644 --- a/apps/mobile/src/mobileSurfaceExistence.test.ts +++ b/apps/mobile/src/mobileSurfaceExistence.test.ts @@ -67,7 +67,7 @@ describe("mobile surface existence (anti stack-drop)", () => { // The feed and the chip list both read the promoted detail, so one piece // of state moves the message and one revert puts it back. expect(composerState).toContain("promoteSteeredQueuedMessages(selectedThreadDetail"); - expect(composerState).toMatch(/buildThreadFeed\(steeredDetail\)/); + expect(composerState).toMatch(/buildThreadFeed\(steeredDetail/); expect(composerState).toMatch(/timelineIds = new Set\(steeredDetail\?\.messages/); // Failure puts it back rather than leaving a bubble the agent never got. expect(composerState).toMatch( diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx index 488766f36954..7c2c037eed33 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter" type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "@t3tools/mobile-markdown-text/types"; diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 403f32a1de48..7ee4d21b1560 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/ type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "@t3tools/mobile-markdown-text/types"; diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 1b9e495209df..811da69b2935 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -84,6 +84,10 @@ export interface Preferences { readonly ownershipRelation?: "created" | "participated" | "both"; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; + /** Undefined preserves the default expanded Settled shelf. */ + readonly threadListV2SettledShelfExpanded?: boolean; + /** Undefined preserves the default collapsed Snoozed shelf. */ + readonly threadListV2SnoozedShelfExpanded?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -151,6 +155,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { ownershipRelation?: "created" | "participated" | "both"; autoSettleOnMerge?: boolean; planModeEnabled?: boolean; + threadListV2SettledShelfExpanded?: boolean; + threadListV2SnoozedShelfExpanded?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -259,6 +265,12 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if (typeof parsed.threadListV2SettledShelfExpanded === "boolean") { + preferences.threadListV2SettledShelfExpanded = parsed.threadListV2SettledShelfExpanded; + } + if (typeof parsed.threadListV2SnoozedShelfExpanded === "boolean") { + preferences.threadListV2SnoozedShelfExpanded = parsed.threadListV2SnoozedShelfExpanded; + } return preferences; } diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index f93fcd76398b..a6608acce4e9 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -17,20 +17,37 @@ const EMPTY_ASSET_URLS_ATOM = Atom.make([] as Array = []; const EMPTY_MESSAGE_ID_SET: ReadonlySet = new Set(); /** Set-minus that keeps the current reference when nothing was removed. */ @@ -130,7 +136,7 @@ export function useThreadDraftForThread(input: { } export function useThreadComposerState() { - const { selectedThread: selectedThreadShell } = useThreadSelection(); + const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); @@ -139,6 +145,12 @@ export function useThreadComposerState() { // failure puts them back in the queue). const [steeringQueuedMessageIds, setSteeringQueuedMessageIds] = useState>(EMPTY_MESSAGE_ID_SET); + const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< + Record> + >({}); + const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { + reportFailure: false, + }); useEffect(() => { ensureComposerDraftsLoaded(); @@ -168,13 +180,6 @@ export function useThreadComposerState() { () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), [queuedMessagesByThreadKey, selectedThreadKey], ); - - // ── Older-history lazy-load (shared engine; see useOlderThreadActivities) ── - // The detail snapshot windows activities to the most recent page (the server - // sets `hasMoreActivities`); older pages are fetched on demand and prepended. - const loadThreadActivities = useAtomCommand(orchestrationEnvironment.loadThreadActivities, { - reportFailure: false, - }); const steerQueuedMessage = useAtomCommand(threadEnvironment.steerQueuedMessage, { label: "steer queued message", }); @@ -198,8 +203,17 @@ export function useThreadComposerState() { if (!steeredDetail) { return []; } - return buildThreadFeed(steeredDetail); - }, [steeredDetail]); + const submissions = selectedThreadKey + ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) + : []; + return buildThreadFeed(steeredDetail, { + localMessages: submissions.flatMap((submission) => + submission.status === "interrupted" + ? [] + : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], + ), + }); + }, [feedbackSubmissionsByThreadKey, selectedThreadKey, steeredDetail]); const composerQueueItems = useMemo(() => { type QueueItem = { @@ -306,6 +320,70 @@ export function useThreadComposerState() { return null; } + const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( + (entry) => entry.instanceId === thread.modelSelection.instanceId, + ); + const feedbackCommand = + attachments.length === 0 && + (provider?.driver === "codex" || thread.session?.providerName === "codex") + ? parseCodexFeedbackCommand(text) + : null; + if (feedbackCommand) { + if (thread.session === null) { + Alert.alert("Start a Codex thread first", "Send a message before you submit feedback."); + return null; + } + const metadata = makeQueuedMessageMetadata(); + const result = await submitCodexFeedback({ + submission: { + id: MessageId.make(metadata.messageId), + command: text, + createdAt: metadata.createdAt, + }, + clearDraft: () => clearComposerDraftContent(threadKey), + onUpdate: (submission) => { + setFeedbackSubmissionsByThreadKey((current) => { + const existing = current[threadKey] ?? []; + const found = existing.some((entry) => entry.id === submission.id); + return { + ...current, + [threadKey]: found + ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) + : [...existing, submission], + }; + }); + }, + upload: () => + uploadThreadFeedback({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + ...feedbackCommand, + }, + }), + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return null; + } + const error = Cause.squash(result.cause); + Alert.alert( + "Could not send feedback to OpenAI", + error instanceof Error ? error.message : "An error occurred.", + ); + return null; + } + const feedbackId = result.value.feedbackId; + Alert.alert("Feedback sent to OpenAI", `Thread ID: ${feedbackId}`, [ + { text: "OK", style: "cancel" }, + { + text: "Copy ID", + onPress: () => copyTextWithHaptic(feedbackId, { target: "Codex feedback thread ID" }), + }, + ]); + return null; + } + const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); // Enqueue updates the in-memory outbox synchronously so the feed can paint @@ -334,7 +412,12 @@ export function useThreadComposerState() { }); clearComposerDraftContent(threadKey); return messageId; - }, [selectedThreadDetail, selectedThreadShell]); + }, [ + selectedEnvironmentRuntime?.serverConfig?.providers, + selectedThreadDetail, + selectedThreadShell, + uploadThreadFeedback, + ]); const onSteerQueuedMessage = useCallback( async (messageId: MessageId) => { diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 49e34eeacaf3..4bb5ccf961ac 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, @@ -20,13 +27,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.listStatus({ 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/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 6de9170195f6..f2583498abca 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -8,6 +8,7 @@ import { ProviderDriverKind, type OrchestrationEvent, type OrchestrationThread, + type ProviderApprovalDecision, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -200,14 +201,14 @@ export interface OrchestrationIntegrationHarness { requestId: string, predicate: (row: { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; }) => boolean, timeoutMs?: number, ) => Effect.Effect< { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; }, never @@ -496,7 +497,7 @@ export const makeOrchestrationIntegrationHarness = ( row, ): row is { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; } => row !== null && predicate(row), `pending approval '${requestId}'`, @@ -504,7 +505,7 @@ export const makeOrchestrationIntegrationHarness = ( ) as Effect.Effect< { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; }, never diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts new file mode 100644 index 000000000000..2ca6a4a4ffc4 --- /dev/null +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -0,0 +1,367 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EnvironmentId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { HttpServer } from "effect/unstable/http"; + +import * as EnvironmentAuth from "../src/auth/EnvironmentAuth.ts"; +import * as ServiceLauncherClient from "../src/cloud/serviceLauncherClient.ts"; +import * as ServerConfig from "../src/config.ts"; +import * as ServerEnvironment from "../src/environment/ServerEnvironment.ts"; +import * as Keybindings from "../src/keybindings.ts"; +import { OrchestrationLayerLive } from "../src/orchestration/runtimeLayer.ts"; +import * as OrchestrationEngine from "../src/orchestration/Services/OrchestrationEngine.ts"; +import * as OrchestrationReactor from "../src/orchestration/Services/OrchestrationReactor.ts"; +import { OrphanSessionRecovery } from "../src/orchestration/Services/OrphanSessionRecovery.ts"; +import * as ProjectionSnapshotQuery from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; +import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; +import * as ExternalLauncher from "../src/process/externalLauncher.ts"; +import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import * as ProviderService from "../src/provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "../src/provider/Services/ProviderSessionDirectory.ts"; +import * as ProviderSessionReaper from "../src/provider/Services/ProviderSessionReaper.ts"; +import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; +import * as ServerLifecycleEvents from "../src/serverLifecycleEvents.ts"; +import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts"; +import * as ServerSettings from "../src/serverSettings.ts"; +import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts"; + +const providerInstanceId = ProviderInstanceId.make("codex"); +const projectId = ProjectId.make("project-startup-orphan"); +const threadId = ThreadId.make("thread-startup-orphan"); +const stoppedBindingThreadId = ThreadId.make("thread-startup-orphan-stopped-binding"); +const resumeCursor = { schemaVersion: 1, sessionId: "provider-session-before-restart" }; +const stoppedBindingResumeCursor = { + schemaVersion: 1, + sessionId: "provider-session-stopped-before-restart", +}; + +const makePersistedRuntimeLayer = (dbPath: string) => { + const persistence = makeSqlitePersistenceLive(dbPath); + const orchestration = OrchestrationLayerLive.pipe( + Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provideMerge(persistence), + ); + const directory = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + Layer.provide(persistence), + ); + return Layer.mergeAll(orchestration, directory); +}; + +const startupDependencies = Layer.mergeAll( + Layer.mock(Keybindings.Keybindings)({ + start: Effect.void, + }), + ServerSettings.layerTest(), + Layer.succeed(OrchestrationReactor.OrchestrationReactor, { + start: () => Effect.void, + }), + Layer.succeed(ProviderSessionReaper.ProviderSessionReaper, { + start: () => Effect.void, + }), + ServerLifecycleEvents.layer, + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-startup-orphan")), + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("environment-startup-orphan"), + label: "Startup orphan test", + version: "test", + platform: { os: "linux", arch: "x64" }, + capabilities: {}, + } as never), + }), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + issueStartupPairingUrl: (baseUrl: string) => Effect.succeed(`${baseUrl}/pair`), + }), + Layer.mock(ExternalLauncher.ExternalLauncher)({ + launchBrowser: () => Effect.void, + }), + Layer.succeed(ServiceLauncherClient.ServiceLauncherClient, { + managed: false, + requestUpdate: () => Effect.die("unused"), + prepareTrial: Effect.sync(() => undefined), + }), + Layer.succeed( + HttpServer.HttpServer, + HttpServer.HttpServer.of({ + address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 3773 }, + serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], + }), + ), + AnalyticsService.layerTest, + Layer.mock(OrphanSessionRecovery)({ + hasLiveProcess: () => Effect.succeed(false), + settleThread: () => Effect.void, + settleIfOrphan: () => Effect.succeed(false), + settleAllAfterServerRestart: () => Effect.succeed({ settledSessions: 0, settledRuntimes: 0 }), + }), + Layer.succeed(ProviderService.ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + compactSession: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: () => Effect.die("unused"), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + uploadFeedback: () => Effect.die("unused"), + streamEvents: Stream.empty, + }), +); + +it.effect( + "recovers a persisted starting session before opening the command gate after restart", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const firstRuntime = makePersistedRuntimeLayer(config.dbPath); + const now = yield* DateTime.now; + const createdAt = DateTime.formatIso(now); + + yield* Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("command-create-project"), + projectId, + title: "Startup orphan project", + workspaceRoot: "/tmp/startup-orphan-project", + defaultModelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("command-create-thread"), + threadId, + projectId, + title: "Startup orphan thread", + modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("command-start-pending-turn"), + threadId, + message: { + messageId: MessageId.make("message-pending-before-restart"), + role: "user", + text: "Persist this queued turn before restart", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("command-mark-session-starting"), + threadId, + session: { + threadId, + status: "starting", + providerName: "codex", + providerInstanceId, + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running", + resumeCursor, + runtimePayload: { activeTurnId: null, unrelated: "preserve-me" }, + runtimeMode: "full-access", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("command-create-stopped-binding-thread"), + threadId: stoppedBindingThreadId, + projectId, + title: "Startup orphan with stopped binding", + modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("command-start-stopped-binding-pending-turn"), + threadId: stoppedBindingThreadId, + message: { + messageId: MessageId.make("message-stopped-binding-pending-before-restart"), + role: "user", + text: "Persist another queued turn before restart", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("command-mark-stopped-binding-session-starting"), + threadId: stoppedBindingThreadId, + session: { + threadId: stoppedBindingThreadId, + status: "starting", + providerName: "codex", + providerInstanceId, + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + yield* directory.upsert({ + threadId: stoppedBindingThreadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "stopped", + resumeCursor: stoppedBindingResumeCursor, + runtimePayload: { activeTurnId: "stale", unrelated: "also-preserve-me" }, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(firstRuntime)); + + const secondRuntime = makePersistedRuntimeLayer(config.dbPath); + const startupLayer = ServerRuntimeStartup.layer.pipe( + Layer.provideMerge(secondRuntime), + Layer.provideMerge(startupDependencies), + ); + + const result = yield* Effect.gen(function* () { + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const sql = yield* SqlClient.SqlClient; + + yield* startup.markHttpListening; + yield* startup.awaitCommandReady; + + const restartedThread = Option.getOrThrow(yield* query.getThreadDetailById(threadId)); + const restartedStoppedBindingThread = Option.getOrThrow( + yield* query.getThreadDetailById(stoppedBindingThreadId), + ); + const pendingRows = yield* sql<{ readonly threadId: string }>` + SELECT thread_id AS "threadId" + FROM projection_turns + WHERE thread_id IN (${threadId}, ${stoppedBindingThreadId}) + AND turn_id IS NULL + AND state = 'pending' + `; + const settleExit = yield* Effect.exit( + engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("command-settle-after-restart"), + threadId, + }), + ); + const snoozeExit = yield* Effect.exit( + engine.dispatch({ + type: "thread.snooze", + commandId: CommandId.make("command-snooze-after-restart"), + threadId, + snoozedUntil: DateTime.formatIso(DateTime.add(now, { hours: 1 })), + }), + ); + const newTurnExit = yield* Effect.exit( + engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("command-new-turn-after-restart"), + threadId, + message: { + messageId: MessageId.make("message-new-turn-after-restart"), + role: "user", + text: "Continue immediately after restart", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt, + }), + ); + const binding = Option.getOrThrow(yield* directory.getBinding(threadId)); + const stoppedBinding = Option.getOrThrow( + yield* directory.getBinding(stoppedBindingThreadId), + ); + + return { + sessionStatus: restartedThread.session?.status, + activeTurnId: restartedThread.session?.activeTurnId, + latestTurn: restartedThread.latestTurn, + pendingTurnCount: pendingRows.length, + settleSucceeded: Exit.isSuccess(settleExit), + snoozeSucceeded: Exit.isSuccess(snoozeExit), + newTurnSucceeded: Exit.isSuccess(newTurnExit), + bindingStatus: binding.status, + resumeCursor: binding.resumeCursor, + runtimePayload: binding.runtimePayload, + stoppedBindingSessionStatus: restartedStoppedBindingThread.session?.status, + stoppedBindingStatus: stoppedBinding.status, + stoppedBindingResumeCursor: stoppedBinding.resumeCursor, + stoppedBindingRuntimePayload: stoppedBinding.runtimePayload, + }; + }).pipe(Effect.provide(startupLayer)); + + assert.deepStrictEqual(result, { + sessionStatus: "error", + activeTurnId: null, + latestTurn: null, + pendingTurnCount: 0, + settleSucceeded: true, + snoozeSucceeded: true, + newTurnSucceeded: true, + bindingStatus: "stopped", + resumeCursor, + runtimePayload: { activeTurnId: null, unrelated: "preserve-me" }, + stoppedBindingSessionStatus: "error", + stoppedBindingStatus: "stopped", + stoppedBindingResumeCursor, + stoppedBindingRuntimePayload: { + activeTurnId: null, + unrelated: "also-preserve-me", + }, + }); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-orphaned-provider-session-startup-", + }).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), +); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index fd257dd2036b..6acf12cdfcf2 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -311,6 +311,44 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues an exact capability for a saved favicon outside the workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-workspace-", + }); + const pictures = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-pictures-", + }); + const externalPath = path.join(pictures, "custom.png"); + const siblingPath = path.join(pictures, "sibling.png"); + yield* fileSystem.writeFile(externalPath, new Uint8Array([1, 2, 3])); + yield* fileSystem.writeFile(siblingPath, new Uint8Array([4, 5, 6])); + const canonicalPath = yield* fileSystem.realPath(externalPath); + const canonicalSiblingPath = yield* fileSystem.realPath(siblingPath); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + projectFaviconPath: externalPath, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + + expect(result.sourcePath).toBe(externalPath); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.png$/); + expect( + yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), + ).toEqual({ kind: "file", path: canonicalPath }); + const tamperedSuffixResult = yield* resolveAsset( + suffix.slice(0, separatorIndex), + "sibling.png", + ); + expect(tamperedSuffixResult).toEqual({ kind: "file", path: canonicalPath }); + expect(tamperedSuffixResult).not.toEqual({ kind: "file", path: canonicalSiblingPath }); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("ignores a client favicon path hint", () => 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 d8903f4921a4..395742471fc1 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -88,6 +88,12 @@ const AssetClaimsSchema = Schema.Union([ relativePath: Schema.NullOr(Schema.String), expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("project-favicon-external"), + filePath: Schema.String, + expiresAt: Schema.Number, + }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -124,6 +130,17 @@ const optionOnNotFound = ( }), ); +const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* ( + filePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const canonicalFile = yield* optionOnNotFound(fileSystem.realPath(filePath)); + if (Option.isNone(canonicalFile)) return null; + + const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value)); + return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null; +}); + const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { const fileSystem = yield* FileSystem.FileSystem; @@ -319,13 +336,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; - if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) { + const isExternalOverride = + faviconPath !== null && + input.projectFaviconPath !== undefined && + path.isAbsolute(input.projectFaviconPath) && + path.normalize(faviconPath) === path.normalize(input.projectFaviconPath); + const relativePath = + faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null; + const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath; + if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } - sourcePath = relativePath ?? undefined; - const canonicalFaviconPath = relativePath - ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + sourcePath = sourceFaviconPath ?? undefined; + const canonicalFaviconPath = sourceFaviconPath + ? yield* ( + isExternalOverride + ? resolveCanonicalFile(sourceFaviconPath) + : resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: sourceFaviconPath }) + ).pipe( Effect.mapError( (cause) => new AssetProjectFaviconInspectionError({ @@ -335,27 +363,35 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ) : null; - if (relativePath && !canonicalFaviconPath) { + if (sourceFaviconPath && !canonicalFaviconPath) { return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, }); } - claims = { - version: 1, - kind: "project-favicon", - workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceResolutionError({ - resource: input.resource, - cause, - }), - ), - ), - relativePath, - expiresAt, - }; - if (relativePath && canonicalFaviconPath) { + claims = + isExternalOverride && canonicalFaviconPath + ? { + version: 1, + kind: "project-favicon-external", + filePath: canonicalFaviconPath, + expiresAt, + } + : { + version: 1, + kind: "project-favicon", + workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceResolutionError({ + resource: input.resource, + cause, + }), + ), + ), + relativePath, + expiresAt, + }; + if (sourceFaviconPath && canonicalFaviconPath) { const crypto = yield* Crypto.Crypto; const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( Effect.mapError( @@ -376,7 +412,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`; + fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(sourceFaviconPath)}`; } else { fileName = PROJECT_FAVICON_FALLBACK_MARKER; } @@ -394,7 +430,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - if (claims.kind === "project-favicon") { + if (claims.kind === "project-favicon" || claims.kind === "project-favicon-external") { const issuedAt = yield* Clock.currentTimeMillis; expiresAt = (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * @@ -460,6 +496,21 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; } + if (claims.kind === "project-favicon-external") { + const faviconPath = yield* resolveCanonicalFile(claims.filePath).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical asset path.", { + filePath: claims.filePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + return faviconPath === claims.filePath + ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) + : null; + } + const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; diff --git a/apps/server/src/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts new file mode 100644 index 000000000000..cb08d5e4b2f1 --- /dev/null +++ b/apps/server/src/assets/AttachmentUpload.test.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts"; +import { + ATTACHMENT_UPLOAD_ROUTE_PREFIX, + deletePendingAttachment, + issueAttachmentUploadUrl, + storeAttachmentUpload, + validateAttachmentUploadToken, +} from "./AttachmentUpload.ts"; + +const testLayer = ServerSecretStore.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-attachment-upload-" })), + Layer.provideMerge(NodeServices.layer), +); + +const uploadInput = { + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, +} as const; + +describe("AttachmentUpload", () => { + it.effect("signs the attachment metadata and validates the upload token", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + expect(parseThreadSegmentFromAttachmentId(issued.attachmentId)).toBe("pending"); + + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + expect(yield* validateAttachmentUploadToken(token)).toMatchObject({ + kind: "attachment-upload", + attachmentId: issued.attachmentId, + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects tampered and malformed upload tokens", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const [payload, signature] = token.split("."); + + expect(yield* validateAttachmentUploadToken(`${payload}x.${signature}`)).toBeNull(); + expect(yield* validateAttachmentUploadToken(`${token}.extra`)).toBeNull(); + expect(yield* validateAttachmentUploadToken("garbage")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects expired upload tokens", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + + yield* TestClock.adjust("11 minutes"); + expect(yield* validateAttachmentUploadToken(token)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes expired pending uploads while issuing a new upload URL", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const staleId = "pending-00000000-0000-4000-8000-0000000000cc"; + const stalePath = NodePath.join(config.attachmentsDir, `${staleId}.png`); + NodeFS.writeFileSync(stalePath, Buffer.from("pixels")); + NodeFS.utimesSync(stalePath, 0, 0); + + yield* TestClock.adjust("25 hours"); + yield* issueAttachmentUploadUrl(uploadInput); + + expect(NodeFS.existsSync(stalePath)).toBe(false); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("stores the expected bytes without leaving temporary files", () => + 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, new Uint8Array([1, 2, 3]))).toMatchObject({ + ok: false, + status: 400, + }); + expect(yield* storeAttachmentUpload(claims, new Uint8Array(6))).toEqual({ ok: true }); + expect( + NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.png`)), + ).toBe(true); + expect( + NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")), + ).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("deletes pending uploads without deleting thread-owned copies", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const uuid = "00000000-0000-4000-8000-0000000000dd"; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${uuid}.png`); + const claimedPath = NodePath.join(config.attachmentsDir, `thread-1-${uuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + NodeFS.writeFileSync(claimedPath, Buffer.from("pixels")); + + yield* deletePendingAttachment(`pending-${uuid}`); + yield* deletePendingAttachment(`pending-${uuid}`); + yield* deletePendingAttachment(`thread-1-${uuid}`); + + expect(NodeFS.existsSync(pendingPath)).toBe(false); + expect(NodeFS.existsSync(claimedPath)).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts new file mode 100644 index 000000000000..6142b69d7342 --- /dev/null +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -0,0 +1,214 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import { + ATTACHMENT_UPLOAD_URL_TTL_MS, + type AttachmentCreateUploadUrlInput, + AttachmentUploadSigningKeyError, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +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 { + createPendingAttachmentId, + parseThreadSegmentFromAttachmentId, + PENDING_ATTACHMENT_THREAD_SEGMENT, + resolveAttachmentPathById, + sweepStalePendingAttachments, +} from "../attachmentStore.ts"; +import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { inferImageExtension } from "../imageMime.ts"; + +export const ATTACHMENT_UPLOAD_ROUTE_PREFIX = "/api/attachments/upload"; + +// Asset download tokens share this key, but their signed claim kind is different. +const SIGNING_SECRET_NAME = "asset-access-signing-key"; +const PENDING_ATTACHMENT_SWEEP_INTERVAL_MS = 15 * 60_000; +const lastPendingSweepByDirectory = new Map(); + +const AttachmentUploadClaims = 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, +}); +export type AttachmentUploadClaims = typeof AttachmentUploadClaims.Type; + +const attachmentUploadClaimsJson = Schema.fromJsonString(AttachmentUploadClaims); +const decodeAttachmentUploadClaims = Schema.decodeUnknownOption(attachmentUploadClaimsJson); +const encodeAttachmentUploadClaims = Schema.encodeSync(attachmentUploadClaimsJson); + +function decodeClaims(encodedPayload: string): AttachmentUploadClaims | null { + try { + return Option.getOrNull(decodeAttachmentUploadClaims(base64UrlDecodeUtf8(encodedPayload))); + } catch { + return null; + } +} + +const loadSigningSecret = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); +}); + +export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(function* ( + input: AttachmentCreateUploadUrlInput, +) { + const secret = yield* loadSigningSecret.pipe( + Effect.mapError((cause) => new AttachmentUploadSigningKeyError({ cause })), + ); + const config = yield* ServerConfig.ServerConfig; + const nowMs = yield* Clock.currentTimeMillis; + const previousSweep = lastPendingSweepByDirectory.get(config.attachmentsDir); + if ( + previousSweep === undefined || + nowMs - previousSweep >= PENDING_ATTACHMENT_SWEEP_INTERVAL_MS + ) { + lastPendingSweepByDirectory.set(config.attachmentsDir, nowMs); + const swept = sweepStalePendingAttachments({ + attachmentsDir: config.attachmentsDir, + nowMs, + }); + if (swept.deleted > 0) { + yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted }); + } + } + + const attachmentId = createPendingAttachmentId(); + const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS; + const encodedPayload = base64UrlEncode( + encodeAttachmentUploadClaims({ + version: 1, + kind: "attachment-upload", + attachmentId, + name: input.name, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + expiresAt, + }), + ); + + return { + attachmentId, + relativeUrl: `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signPayload(encodedPayload, secret)}`, + expiresAt, + }; +}); + +export const validateAttachmentUploadToken = Effect.fn("AttachmentUpload.validateToken")(function* ( + token: string, +) { + const [encodedPayload, signature, unexpectedSegment] = token.split("."); + if (!encodedPayload || !signature || unexpectedSegment) { + return null; + } + + const secret = yield* loadSigningSecret.pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the attachment upload signing key.", { cause }), + ), + Effect.orElseSucceed(() => null), + ); + if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) { + return null; + } + + const claims = decodeClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) { + return null; + } + return claims; +}); + +export type StoreAttachmentUploadResult = + | { readonly ok: true } + | { readonly ok: false; readonly status: number; readonly detail: string }; + +export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* ( + claims: AttachmentUploadClaims, + bytes: Uint8Array, +) { + if (bytes.byteLength !== claims.sizeBytes) { + return { + ok: false, + status: 400, + detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`, + } satisfies StoreAttachmentUploadResult; + } + + const config = yield* ServerConfig.ServerConfig; + const extension = inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name }); + const relativePath = `${claims.attachmentId}${extension}`; + const finalPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath, + }); + const partPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath: `${relativePath}.${NodeCrypto.randomUUID()}.part`, + }); + if (!finalPath || !partPath) { + return { ok: false, status: 500, detail: "Failed to resolve attachment path." }; + } + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* Effect.gen(function* () { + yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true }); + yield* fileSystem.writeFile(partPath, bytes); + 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.as({ + ok: false, + status: 500, + detail: "Failed to persist upload.", + } satisfies StoreAttachmentUploadResult), + ), + ), + ); +}); + +export const deletePendingAttachment = Effect.fn("AttachmentUpload.deletePending")(function* ( + attachmentId: string, +) { + if (parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT) { + return; + } + + const config = yield* ServerConfig.ServerConfig; + const attachmentPath = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId, + }); + if (!attachmentPath) { + return; + } + + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(attachmentPath, { force: true }).pipe(Effect.orElseSucceed(() => {})); +}); diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index e21d9cf62cf5..5e782e55407f 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -7,8 +7,12 @@ import { describe, expect, it } from "vite-plus/test"; import { createAttachmentId, + createPendingAttachmentId, + parseAttachmentUuid, + planAttachmentClaim, parseThreadSegmentFromAttachmentId, resolveAttachmentPathById, + sweepStalePendingAttachments, } from "./attachmentStore.ts"; describe("attachmentStore", () => { @@ -44,6 +48,16 @@ describe("attachmentStore", () => { expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo"); }); + it("reserves the pending attachment segment", () => { + const pendingId = createPendingAttachmentId(); + expect(parseThreadSegmentFromAttachmentId(pendingId)).toBe("pending"); + expect(parseAttachmentUuid(pendingId)).toMatch(/^[a-f0-9-]{36}$/); + expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending")!)).toBe("_pending"); + expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending_thread")!)).toBe( + "pending_thread", + ); + }); + it("resolves attachment path by id using the extension that exists on disk", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), @@ -77,4 +91,75 @@ describe("attachmentStore", () => { 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-"), + ); + try { + const uuid = "00000000-0000-4000-8000-000000000001"; + const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + + const claim = planAttachmentClaim({ + attachmentsDir, + threadId: "thread-1", + attachmentId: `pending-${uuid}`, + }); + expect(claim).toMatchObject({ + ok: true, + currentPath: pendingPath, + }); + if (!claim.ok) { + return; + } + expect(parseThreadSegmentFromAttachmentId(claim.finalId)).toBe("thread-1"); + expect(parseAttachmentUuid(claim.finalId)).not.toBe(uuid); + expect(claim.finalPath).toBe(NodePath.join(attachmentsDir, `${claim.finalId}.png`)); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("rejects thread-owned attachments even when thread segments collide", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-ownership-"), + ); + try { + const attachmentId = "a-b-00000000-0000-4000-8000-000000000003"; + NodeFS.writeFileSync(NodePath.join(attachmentsDir, `${attachmentId}.png`), "pixels"); + + expect(planAttachmentClaim({ attachmentsDir, threadId: "a b", attachmentId })).toEqual({ + ok: false, + reason: "attachment must be a pending upload", + }); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("removes expired pending and partial files without touching thread attachments", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-sweep-"), + ); + try { + const now = 1_800_000_000_000; + 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 threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`); + const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`); + for (const filePath of [pendingPath, threadPath, partialPath]) { + NodeFS.writeFileSync(filePath, Buffer.from("pixels")); + NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds); + } + + expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 }); + expect(NodeFS.existsSync(pendingPath)).toBe(false); + expect(NodeFS.existsSync(partialPath)).toBe(false); + expect(NodeFS.existsSync(threadPath)).toBe(true); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db217..d0334bce09f3 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; @@ -19,6 +20,10 @@ const ATTACHMENT_ID_PATTERN = new RegExp( "i", ); +export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending"; +export const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000; + export function toSafeThreadAttachmentSegment(threadId: string): string | null { const segment = threadId .trim() @@ -31,7 +36,19 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null { if (segment.length === 0) { return null; } - return segment; + return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment; +} + +export function createPendingAttachmentId(): string { + return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`; +} + +export function parseAttachmentUuid(attachmentId: string): string | null { + const normalizedId = normalizeAttachmentRelativePath(attachmentId); + if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { + return null; + } + return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null; } export function createAttachmentId(threadId: string): string | null { @@ -96,6 +113,105 @@ export function resolveAttachmentPathById(input: { return null; } +export type AttachmentClaimPlan = + | { + readonly ok: true; + readonly finalId: string; + readonly currentPath: string; + readonly finalPath: string; + } + | { readonly ok: false; readonly reason: string }; + +export function planAttachmentClaim(input: { + readonly attachmentsDir: string; + readonly threadId: string; + readonly attachmentId: string; +}): AttachmentClaimPlan { + const uuid = parseAttachmentUuid(input.attachmentId); + const requestedSegment = parseThreadSegmentFromAttachmentId(input.attachmentId); + if (!uuid || !requestedSegment) { + return { ok: false, reason: "invalid attachment id" }; + } + + if (!toSafeThreadAttachmentSegment(input.threadId)) { + return { ok: false, reason: "invalid thread id" }; + } + if (requestedSegment !== PENDING_ATTACHMENT_THREAD_SEGMENT) { + return { ok: false, reason: "attachment must be a pending upload" }; + } + + const currentPath = resolveAttachmentPathById({ + attachmentsDir: input.attachmentsDir, + attachmentId: input.attachmentId, + }); + if (!currentPath) { + return { ok: false, reason: "attachment not found (removed or expired)" }; + } + const finalId = createAttachmentId(input.threadId); + if (!finalId) { + return { ok: false, reason: "failed to create attachment id" }; + } + + const expectedFinalPath = resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: `${finalId}${NodePath.extname(currentPath)}`, + }); + if (!expectedFinalPath) { + return { ok: false, reason: "failed to resolve attachment path" }; + } + return { + ok: true, + finalId, + currentPath, + finalPath: expectedFinalPath, + }; +} + +export function sweepStalePendingAttachments(input: { + readonly attachmentsDir: string; + readonly nowMs: number; +}): { readonly deleted: number } { + let entries: string[]; + try { + entries = NodeFS.readdirSync(input.attachmentsDir); + } catch { + return { deleted: 0 }; + } + + let deleted = 0; + for (const entry of entries) { + const isPartial = entry.endsWith(".part"); + if (!isPartial) { + const attachmentId = parseAttachmentIdFromRelativePath(entry); + if ( + !attachmentId || + parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT + ) { + continue; + } + } + + const resolved = resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: entry, + }); + if (!resolved) { + continue; + } + try { + const maxAgeMs = isPartial ? PARTIAL_UPLOAD_MAX_AGE_MS : PENDING_ATTACHMENT_MAX_AGE_MS; + if (input.nowMs - NodeFS.statSync(resolved).mtimeMs > maxAgeMs) { + NodeFS.unlinkSync(resolved); + deleted += 1; + } + } catch { + continue; + } + } + + return { deleted }; +} + export function parseAttachmentIdFromRelativePath(relativePath: string): string | null { const normalized = normalizeAttachmentRelativePath(relativePath); if (!normalized || normalized.includes("/")) { diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index a6532b7cea84..dbc33ea86ea7 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -47,6 +47,12 @@ describe("RPC authorization scopes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); }); + it("requires permission to operate on a thread before uploading feedback", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.providerUploadFeedback)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2a3710fdc456..6471b450d659 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -91,6 +91,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, + [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, + [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, + [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 334c24ef52fd..1fb01c1f0002 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; @@ -47,6 +48,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi revoke: () => Effect.fail(repositoryFailure), revokeAllExcept: () => Effect.fail(repositoryFailure), setLastConnectedAt: () => Effect.void, + setClientConnection: () => Effect.void, }); const failingSessionLookupCredentialLayer = Layer.effect( @@ -315,4 +317,35 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); + it.effect("records client connection metadata without clearing prior values", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const issued = yield* sessions.issue({ + subject: "client-connection-test", + method: "bearer-access-token", + }); + const readRow = sql<{ + readonly surface: string | null; + readonly appVersion: string | null; + }>` + SELECT client_surface AS "surface", client_app_version AS "appVersion" + FROM auth_sessions + WHERE session_id = ${issued.sessionId} + `; + + yield* sessions.recordClientConnection(issued.sessionId, { + surface: "mobile", + appVersion: "1.2.0", + }); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.2.0" }); + + // A partial report (old or minimal client) must not null out stored data. + yield* sessions.recordClientConnection(issued.sessionId, { appVersion: "1.3.0" }); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); + + yield* sessions.recordClientConnection(issued.sessionId, {}); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); + }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), + ); }); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 40a1c43e0be7..cdcd4a1ac198 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -5,6 +5,7 @@ import { type AuthClientMetadata, type AuthClientSession, type AuthEnvironmentScope, + type ClientSurface, type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -396,6 +397,13 @@ export class SessionStore extends Context.Service< ) => Effect.Effect; readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly recordClientConnection: ( + sessionId: AuthSessionId, + client: { + readonly surface?: ClientSurface | undefined; + readonly appVersion?: string | undefined; + }, + ) => Effect.Effect; } >()("t3/auth/SessionStore") {} @@ -544,6 +552,28 @@ export const make = Effect.gen(function* () { Effect.withSpan("SessionStore.markConnected"), ); + // Best-effort: connection metadata must never block or fail a connect. + const recordClientConnection: SessionStore["Service"]["recordClientConnection"] = ( + sessionId, + client, + ) => + client.surface === undefined && client.appVersion === undefined + ? Effect.void + : authSessions + .setClientConnection({ + sessionId, + surface: client.surface ?? null, + appVersion: client.appVersion ?? null, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to record session client connection metadata.").pipe( + Effect.annotateLogs({ sessionId, cause }), + ), + ), + Effect.withSpan("SessionStore.recordClientConnection"), + ); + const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); @@ -912,6 +942,7 @@ export const make = Effect.gen(function* () { revokeAllExcept, markConnected, markDisconnected, + recordClientConnection, }); }); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index d233e156e3ac..c2f15d9b860c 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -13,6 +13,7 @@ import { connectCommand } from "./cli/connect.ts"; import { pairCommand } from "./cli/pair.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; +import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; @@ -67,7 +68,13 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => export const cli = makeCli(); -if (import.meta.main) { +if ( + isEntrypoint({ + moduleUrl: import.meta.url, + entryPath: process.argv[1], + runtimeMain: import.meta.main, + }) +) { Command.run(cli, { version: packageJson.version }).pipe( Effect.scoped, Effect.provide(CliRuntimeLayer), diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 1314ccfb9361..a999f81b2898 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -56,17 +56,26 @@ const macPlan = { logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", }; +const macInstallerPath = + "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; +const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; it("keeps launchd pinned to the stable launcher rather than a versioned server", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain("/opt/homebrew/bin/node"); expect(plist).toContain("/Users/theo/.t3/runtime/service-launcher.mjs"); expect(plist).not.toContain("versions/1.2.3"); }); +it("preserves the installer's provider search path in the launch agent", () => { + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); + + expect(plist).toContain(` PATH\n ${macInstallerPath}`); +}); + it("restarts the launch agent on the systemd cadence", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain("RunAtLoad\n "); expect(plist).toContain("KeepAlive\n "); @@ -75,7 +84,7 @@ it("restarts the launch agent on the systemd cadence", () => { }); it("appends both stdio streams to the boot service log", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain( "StandardOutPath\n /Users/theo/.t3/userdata/logs/boot-service.log", @@ -88,15 +97,17 @@ it("appends both stdio streams to the boot service log", () => { it("escapes XML in host paths", () => { const plist = BootService.renderBootServicePlist( { ...macPlan, baseDir: "/Users/theo/T3 & " }, - { homeDir: "/Users/theo" }, + { homeDir: "/Users/theo", environmentPath: "/Users/theo/Tools & :/usr/bin" }, ); expect(plist).toContain("/Users/theo/T3 & <Co>"); + expect(plist).toContain("/Users/theo/Tools & <Scripts>:/usr/bin"); }); const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", usePinnedLauncher = false, + installerPath = macInstallerPath, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -135,27 +146,33 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( }; }), }); - const service = yield* BootService.make({ - baseDir, - logsDir: path.join(baseDir, "userdata", "logs"), - cliVersion: "1.2.3", - host: { - execPath: "/usr/bin/node", - ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), - }, - }).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, runner), - Effect.provide( - Layer.mergeAll( - Layer.succeed(HostProcessPlatform, platform), - Layer.succeed(HostProcessUserId, 501), - Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), - Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), - ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })), + const makeService = (environmentPath = installerPath) => + BootService.make({ + baseDir, + logsDir: path.join(baseDir, "userdata", "logs"), + cliVersion: "1.2.3", + host: { + execPath: "/usr/bin/node", + ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), + }, + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessUserId, 501), + Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), + Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, + }), + ), + ), ), - ), - ); - return { service, fs, statePath, commands, timeouts, control }; + ); + const service = yield* makeService(); + return { service, makeService, fs, statePath, commands, timeouts, control }; }); it.layer(NodeServices.layer)("boot service install", (it) => { @@ -266,6 +283,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe( true, ); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + ` PATH\n ${macInstallerPath}:/usr/local/bin:/usr/sbin:/sbin`, + ); expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", @@ -303,6 +323,58 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("reconstructs a launch agent search path when the installer has no PATH", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness("darwin", false, ""); + const plan = yield* service.install; + + expect(yield* fs.readFileString(plan.unitPath)).toContain( + " PATH\n /usr/bin:/opt/homebrew/bin:/usr/local/bin:/bin:/usr/sbin:/sbin", + ); + expect((yield* service.status).current).toBe(true); + }), + ); + + it.effect("adds missing provider directories to a minimal installer PATH", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin"); + const plan = yield* service.install; + + expect(yield* fs.readFileString(plan.unitPath)).toContain( + " PATH\n /usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin", + ); + expect((yield* service.status).current).toBe(true); + }), + ); + + it.effect("keeps an installed launch agent current when the process PATH changes", () => + Effect.gen(function* () { + const { service, makeService } = yield* makeHarness("darwin"); + yield* service.install; + + const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin"); + expect((yield* restartedService.status).current).toBe(true); + }), + ); + + it.effect("drops PATH directories that cannot be represented in a launch agent plist", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness( + "darwin", + false, + "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", + ); + const plan = yield* service.install; + const plist = yield* fs.readFileString(plan.unitPath); + + expect(plist).toContain( + " PATH\n /opt/homebrew/bin:/usr/bin:/usr/local/bin:/bin:/usr/sbin:/sbin", + ); + expect(plist).not.toContain("\u0001"); + expect((yield* service.status).current).toBe(true); + }), + ); + it.effect("ignores a bootout for an agent that is not loaded", () => Effect.gen(function* () { const { service, control } = yield* makeHarness("darwin"); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 795bf38e979d..6b7e13d0bbba 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -99,7 +99,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. @@ -127,6 +127,8 @@ export function renderBootServicePlist( ` `, ` EnvironmentVariables`, ` `, + ` PATH`, + ` ${escapeXmlText(options.environmentPath)}`, ` T3CODE_HOME`, ` ${escapeXmlText(plan.baseDir)}`, ` ${BOOT_SERVICE_UNIT_ENV}`, @@ -268,6 +270,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, @@ -287,7 +290,11 @@ export function launchdManager(input: { return { kind: "launchd", unitPath, - render: (plan) => renderBootServicePlist(plan, { homeDir: input.homeDir }), + render: (plan) => + renderBootServicePlist(plan, { + homeDir: input.homeDir, + environmentPath: input.environmentPath, + }), // Without --wait, bootout returns in milliseconds while the job drains // for up to ExitTimeOut, and a bootstrap during the drain fails EIO. // --wait (present on modern macOS, absent from the man page) blocks until @@ -346,6 +353,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; @@ -354,7 +362,12 @@ export function selectBootServiceManager(input: { return systemdManager({ path: input.path, homeDir: input.homeDir }); } if (input.platform === "darwin" && input.uid !== undefined) { - return launchdManager({ path: input.path, homeDir: input.homeDir, uid: input.uid }); + return launchdManager({ + path: input.path, + homeDir: input.homeDir, + uid: input.uid, + environmentPath: input.environmentPath, + }); } return undefined; } @@ -441,12 +454,39 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const platform = yield* HostProcessPlatform; const uid = yield* HostProcessUserId; const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); + const installerPath = yield* Config.string("PATH").pipe(Config.withDefault("")); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; const host = input.host ?? { execPath: hostExecPath }; - - const detectedManager = selectBootServiceManager({ platform, homeDir, uid, path }); + const xmlSafeInstallerDirectories = installerPath.split(":").filter( + (directory) => + directory.length > 0 && + Array.from(directory).every((character) => { + const code = character.charCodeAt(0); + return code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d; + }), + ); + const environmentPath = Array.from( + new Set([ + ...xmlSafeInstallerDirectories, + path.dirname(host.execPath), + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]), + ).join(":"); + + const detectedManager = selectBootServiceManager({ + platform, + homeDir, + uid, + path, + environmentPath, + }); const unitPath = detectedManager?.unitPath ?? ""; const logPath = path.join(input.logsDir, "boot-service.log"); const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); @@ -664,11 +704,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { fs.readFileString(statePath).pipe(Effect.option), ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; + const normalizeUnit = (contents: string) => + detectedManager.kind === "launchd" + ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") + : contents; return { supported: true, installed: true, current: - unit === detectedManager.render(plan) && + normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && launcherExists && runtimeEntryExists && Option.isSome(runtimeSentinel) && diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e678264dde5f..bdff19572fdd 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -7,6 +7,7 @@ * @module ServerConfig */ import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -14,6 +15,8 @@ import * as LogLevel from "effect/LogLevel"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { sweepStalePendingAttachments } from "./attachmentStore.ts"; + export const DEFAULT_PORT = 3773; export const RuntimeMode = Schema.Literals(["web", "desktop"]); @@ -152,6 +155,14 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server ], { concurrency: "unbounded" }, ); + + const swept = sweepStalePendingAttachments({ + attachmentsDir: derivedPaths.attachmentsDir, + nowMs: yield* Clock.currentTimeMillis, + }); + if (swept.deleted > 0) { + yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted }); + } }); const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( diff --git a/apps/server/src/entrypoint.test.ts b/apps/server/src/entrypoint.test.ts new file mode 100644 index 000000000000..56f2c119764a --- /dev/null +++ b/apps/server/src/entrypoint.test.ts @@ -0,0 +1,89 @@ +// @effect-diagnostics nodeBuiltinImport:off - entrypoint detection is a Node filesystem boundary. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { describe, expect, it } from "vite-plus/test"; + +import { isEntrypoint } from "./entrypoint.ts"; + +const makeTempDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-entrypoint-test-")); + +describe("isEntrypoint", () => { + it("uses the runtime answer when Node provides one", () => { + // Node 22.18+ and 24.2+ populate `import.meta.main`; nothing else is consulted. + expect( + isEntrypoint({ + moduleUrl: "file:///somewhere/bin.mjs", + entryPath: "/elsewhere/other.mjs", + runtimeMain: true, + }), + ).toBe(true); + expect( + isEntrypoint({ + moduleUrl: "file:///somewhere/bin.mjs", + entryPath: "/somewhere/bin.mjs", + runtimeMain: false, + }), + ).toBe(false); + }); + + it("matches the entrypoint path when the runtime has no import.meta.main", () => { + // Node 22.16, 22.17 and 23.11 are inside `engines.node` but leave it undefined. + const dir = makeTempDir(); + const entry = NodePath.join(dir, "bin.mjs"); + NodeFS.writeFileSync(entry, ""); + + expect( + isEntrypoint({ + moduleUrl: NodeURL.pathToFileURL(entry).href, + entryPath: entry, + runtimeMain: undefined, + }), + ).toBe(true); + }); + + it("matches through a symlinked entrypoint, as npm and npx install it", () => { + const dir = makeTempDir(); + const real = NodePath.join(dir, "bin.mjs"); + const link = NodePath.join(dir, "t3"); + NodeFS.writeFileSync(real, ""); + NodeFS.symlinkSync(real, link); + + expect( + isEntrypoint({ + moduleUrl: NodeURL.pathToFileURL(real).href, + entryPath: link, + runtimeMain: undefined, + }), + ).toBe(true); + }); + + it("stays false for an imported module that is not the entrypoint", () => { + // This is what keeps `bin.test.ts` from launching the CLI on import. + const dir = makeTempDir(); + const entry = NodePath.join(dir, "bin.mjs"); + const imported = NodePath.join(dir, "cli.mjs"); + NodeFS.writeFileSync(entry, ""); + NodeFS.writeFileSync(imported, ""); + + expect( + isEntrypoint({ + moduleUrl: NodeURL.pathToFileURL(imported).href, + entryPath: entry, + runtimeMain: undefined, + }), + ).toBe(false); + }); + + it("stays false when there is no entrypoint argument", () => { + expect( + isEntrypoint({ + moduleUrl: "file:///somewhere/bin.mjs", + entryPath: undefined, + runtimeMain: undefined, + }), + ).toBe(false); + }); +}); diff --git a/apps/server/src/entrypoint.ts b/apps/server/src/entrypoint.ts new file mode 100644 index 000000000000..1ac083ec5873 --- /dev/null +++ b/apps/server/src/entrypoint.ts @@ -0,0 +1,38 @@ +// @effect-diagnostics nodeBuiltinImport:off +// Entrypoint detection runs before any Effect runtime is built, so it stays on +// Node built-ins. +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; + +/** + * Whether the module identified by `moduleUrl` is the process entrypoint. + * + * `import.meta.main` answers this directly, but it only exists on Node 22.18+ + * and 24.2+. This package's `engines.node` range also accepts 22.16, 22.17 and + * 23.11, where it is `undefined`: an `if (import.meta.main)` guard never runs, + * so the process loads every module and exits 0 without output. Fall back to + * comparing the entrypoint path on those versions. + */ +export const isEntrypoint = (input: { + readonly moduleUrl: string; + readonly entryPath: string | undefined; + readonly runtimeMain: boolean | undefined; +}): boolean => { + if (input.runtimeMain !== undefined) { + return input.runtimeMain; + } + if (input.entryPath === undefined || input.entryPath === "") { + return false; + } + if (input.moduleUrl === NodeURL.pathToFileURL(input.entryPath).href) { + return true; + } + // npm and npx install the CLI as a symlink. Without `--preserve-symlinks` the + // module URL is the resolved real path while `process.argv[1]` keeps the link + // path, so the comparison above misses. + try { + return input.moduleUrl === NodeURL.pathToFileURL(NodeFS.realpathSync(input.entryPath)).href; + } catch { + return false; + } +}; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index ee30d987591d..531d061219f4 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -90,8 +90,10 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.environmentId).toBe(second.environmentId); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); + expect(second.capabilities.attachmentUploads).toBe(true); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd5..907a5d64bdfc 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -146,12 +146,14 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + attachmentUploads: true, pullRequests: true, threadSettlement: true, threadSnooze: true, threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + threadPullRequestLinking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 2892de071296..9fc6dd377d48 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1020,6 +1020,75 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status finds a merged PR after its remote branch was 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/merged-branch-deleted"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/merged-branch-deleted"]); + + // GitHub commonly deletes a pull request's head branch after merge. Git + // removes the remote-tracking ref, but preserves the local branch's + // remote and merge configuration as evidence that it was published. + yield* runGit(repoDir, ["push", "origin", "--delete", "feature/merged-branch-deleted"]); + const configuredRemote = yield* runGit(repoDir, [ + "config", + "--get", + "branch.feature/merged-branch-deleted.remote", + ]); + const configuredMerge = yield* runGit(repoDir, [ + "config", + "--get", + "branch.feature/merged-branch-deleted.merge", + ]); + const trackingRef = yield* runGit(repoDir, [ + "for-each-ref", + "--format=%(refname)", + "refs/remotes/origin/feature/merged-branch-deleted", + ]); + expect(configuredRemote.stdout.trim()).toBe("origin"); + expect(configuredMerge.stdout.trim()).toBe("refs/heads/feature/merged-branch-deleted"); + expect(trackingRef.stdout.trim()).toBe(""); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 215, + title: "Merged branch was deleted", + url: "https://github.com/pingdotgg/t3code/pull/215", + baseRefName: "main", + headRefName: "feature/merged-branch-deleted", + state: "MERGED", + mergedAt: "2026-04-02T15:00:00Z", + updatedAt: "2026-04-02T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.hasUpstream).toBe(false); + expect(status.pr).toEqual({ + number: 215, + title: "Merged branch was deleted", + url: "https://github.com/pingdotgg/t3code/pull/215", + baseRef: "main", + headRef: "feature/merged-branch-deleted", + state: "merged", + updatedAt: "2026-04-02T15:00:00.000Z", + }); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); + }), + ); + it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2679,6 +2748,57 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("create_pr targets the remote default branch when it is not main", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + // A repository whose default branch is master, with no main anywhere. + yield* runGit(repoDir, ["push", "origin", "HEAD:master"]); + yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "master"]); + + yield* runGit(repoDir, ["checkout", "-b", "feature/master-default"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "master-default.txt"), "master default\n"); + yield* runGit(repoDir, ["add", "master-default.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Master default"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + // Mirrors a provider that cannot report a default branch, as the Azure + // DevOps CLI does when it cannot detect the repository. + defaultBranch: "", + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 505, + title: "Master default", + url: "https://github.com/pingdotgg/codething-mvp/pull/505", + baseRefName: "master", + headRefName: "feature/master-default", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect( + ghCalls.some((call) => + call.includes("pr create --base master --head feature/master-default"), + ), + ).toBe(true); + }), + ); + it.effect("returns existing PR metadata for commit/push/pr action", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f507eb112499..e5f0f247847c 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1353,15 +1353,17 @@ export const make = Effect.gen(function* () { * cannot exist for it and asking the provider is a guaranteed-empty API call. * * `git push` writes the remote-tracking ref even without `-u` (how most - * terminal and agent pushes land), which makes this a safer "did it ever - * reach the host" test than looking for upstream config, and the glob spans - * every remote so a fork branch still counts. A repository that tracks no - * remotes at all cannot answer the question, because then every branch looks - * unpublished; it, and any failed probe, keeps the lookup. + * terminal and agent pushes land), and configured upstream metadata survives + * when a merged change request's remote branch is deleted. Together they + * distinguish branches known to have reached a host from genuinely local + * branches. The ref glob spans every remote so a fork branch still counts. A + * repository that tracks no remotes at all cannot answer the question, + * because then every branch looks unpublished; it, and any failed probe, + * keeps the lookup. */ const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( cwd: string, - headContext: Pick, + headContext: Pick, ) { if (headContext.headBranch.length === 0) { return false; @@ -1376,13 +1378,24 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.map((result) => result.stdout.trim().length > 0)); - return yield* Effect.all( - [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], - { concurrency: "unbounded" }, - ).pipe( - Effect.map(([tracksAnyRemote, tracksThisBranch]) => tracksAnyRemote && !tracksThisBranch), - Effect.orElseSucceed(() => false), - ); + return yield* Effect.gen(function* () { + const [configuredRemote, configuredMerge] = yield* Effect.all( + [ + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.remote`), + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.merge`), + ], + { concurrency: "unbounded" }, + ); + if (configuredRemote !== null && configuredMerge !== null) { + return false; + } + + const [tracksAnyRemote, tracksThisBranch] = yield* Effect.all( + [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], + { concurrency: "unbounded" }, + ); + return tracksAnyRemote && !tracksThisBranch; + }).pipe(Effect.orElseSucceed(() => false)); }); const findOpenPr = Effect.fn("findOpenPr")(function* ( @@ -1622,6 +1635,18 @@ export const make = Effect.gen(function* () { return defaultFromProvider; } + // The provider lookup can fail for reasons unrelated to the branch, so fall + // back to what the remote itself records before assuming a name. A repository + // whose default branch is master would otherwise get a base branch that does + // not exist. + const defaultFromRemote = yield* gitCore.resolvePrimaryRemoteName(cwd).pipe( + Effect.flatMap((remoteName) => gitCore.resolveDefaultBranchName(cwd, remoteName)), + Effect.orElseSucceed(() => null), + ); + if (defaultFromRemote) { + return defaultFromRemote; + } + return "main"; }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 5327b3a5cf14..6d42a589f07b 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -90,6 +90,9 @@ export class GitWorkflowService extends Context.Service< readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; + readonly pruneWorktrees: (input: { + readonly cwd: string; + }) => Effect.Effect; readonly createRef: ( input: VcsCreateRefInput, ) => Effect.Effect; @@ -438,6 +441,10 @@ export const make = Effect.gen(function* () { }), ), ), + pruneWorktrees: (input) => + ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( + Effect.andThen(git.pruneWorktrees(input)), + ), createRef: (input) => ensureGitCommand("GitWorkflowService.createRef", input.cwd, { // Creating the branch is pure ref plumbing; only checking it out after diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index ec4d2aae16e6..f85de08d40b4 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -44,4 +44,15 @@ describe("assetResponseHeaders", () => { "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", + "text/html; charset=utf-8", + ); + expect(assetResponseHeaders("/workspace/PAGE.HTM")).toHaveProperty( + "Content-Type", + "text/html; charset=utf-8", + ); + }); }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index fea807813b0d..5ed4fef4fd40 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -28,6 +28,11 @@ import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { + ATTACHMENT_UPLOAD_ROUTE_PREFIX, + storeAttachmentUpload, + validateAttachmentUploadToken, +} from "./assets/AttachmentUpload.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -54,10 +59,14 @@ const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; export function assetResponseHeaders(filePath: string): Record { + const lowerPath = filePath.toLowerCase(); return { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", - ...(filePath.toLowerCase().endsWith(".svg") + ...(lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") + ? { "Content-Type": "text/html; charset=utf-8" } + : {}), + ...(lowerPath.endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } : {}), }; @@ -236,6 +245,51 @@ export const assetRouteLayer = HttpRouter.add( const staticCompressionCache = makeStaticCompressionCache(); +export const attachmentUploadRouteLayer = HttpRouter.add( + "POST", + `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + const token = url.value.pathname.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + if (!token) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const contentLengthHeader = request.headers["content-length"]; + if ( + contentLengthHeader !== undefined && + (!Number.isInteger(Number(contentLengthHeader)) || + Number(contentLengthHeader) !== claims.sizeBytes) + ) { + return HttpServerResponse.text("Content-Length must match the upload size.", { + status: 400, + }); + } + + 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)); + return stored.ok + ? HttpServerResponse.empty({ status: 204 }) + : HttpServerResponse.text(stored.detail, { status: stored.status }); + }), +); + export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 4925913aadc8..aac492dbf19b 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,6 +195,7 @@ 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.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/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 103b267d2954..32f249c251d5 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -342,11 +342,17 @@ export function projectActivityPayload( return activity; } + const itemStatus = asRecord(data.item)?.status; + const projectedPayload = + payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") + ? { ...payload, status: itemStatus } + : payload; + if (payload.itemType === "mcp_tool_call") { return { ...activity, payload: { - ...payload, + ...projectedPayload, data: projectMcpToolCallData(data), }, }; @@ -384,7 +390,7 @@ export function projectActivityPayload( return { ...activity, payload: { - ...payload, + ...projectedPayload, data: projectedData, }, }; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index a6e8c85f27fd..f292591e566e 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -126,6 +126,7 @@ function createProviderServiceHarness( }, }), rollbackConversation, + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 3cb5ad1939a6..ca7983daced0 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -1457,4 +1457,55 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + + it("stamps the dispatching client's origin onto persisted event metadata", async () => { + const createdAt = now(); + const system = await createOrchestrationSystem(); + const { engine } = system; + + await system.run( + engine.dispatch( + { + type: "project.create", + commandId: CommandId.make("cmd-origin-project-create"), + projectId: asProjectId("project-origin"), + title: "Origin Project", + workspaceRoot: "/tmp/project-origin", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }, + { origin: { surface: "mobile", appVersion: "1.2.3" } }, + ), + ); + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-no-origin-project-create"), + projectId: asProjectId("project-no-origin"), + title: "No Origin Project", + workspaceRoot: "/tmp/project-no-origin", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + + const events = await system.run( + Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), + ); + const withOrigin = events.find((event) => event.commandId === "cmd-origin-project-create"); + const withoutOrigin = events.find( + (event) => event.commandId === "cmd-no-origin-project-create", + ); + + expect(withOrigin?.metadata.origin).toEqual({ surface: "mobile", appVersion: "1.2.3" }); + expect(withoutOrigin?.metadata.origin).toBeUndefined(); + + await system.dispose(); + }); }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index dd1658cc0369..235d0c7f342a 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,4 +1,10 @@ -import type { OrchestrationEvent, ProjectId, ThreadId } from "@t3tools/contracts"; +import type { + OrchestrationClientOrigin, + OrchestrationEvent, + OrchestrationReadModel, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; import { OrchestrationCommand } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -55,6 +61,7 @@ const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvar interface CommandEnvelope { command: OrchestrationCommand; + origin: OrchestrationClientOrigin | undefined; result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; startedAtMs: number; } @@ -183,7 +190,16 @@ const makeOrchestrationEngine = Effect.gen(function* () { }), ), ); - const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; + const plannedEvents = Array.isArray(eventBase) ? eventBase : [eventBase]; + // Stamp the dispatching client's origin onto every event the command + // produced. The decider stays pure; attribution is an engine concern. + const eventBases = + envelope.origin === undefined + ? plannedEvents + : plannedEvents.map((planned) => ({ + ...planned, + metadata: { ...planned.metadata, origin: envelope.origin }, + })); const committedCommand = yield* sql .withTransaction( Effect.gen(function* () { @@ -339,11 +355,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) => eventStore.readFromSequence(fromSequenceExclusive, limit); - const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + const dispatch: OrchestrationEngineShape["dispatch"] = (command, options) => Effect.gen(function* () { const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); yield* Queue.offer(commandQueue, { command, + origin: options?.origin, result, startedAtMs: yield* Clock.currentTimeMillis, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index d159057af52d..0e3a180085ca 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -174,6 +174,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({ diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 24fcbf93b122..6dc7ae67d9c2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -145,6 +145,29 @@ const PENDING_USER_INPUT_ACTIVITY_KINDS = [ "provider.user-input.respond.failed", ] as const; +// A full refresh loads all thread history, so skip events that cannot change the summary. +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 { @@ -689,6 +712,7 @@ 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, @@ -886,6 +910,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; @@ -954,11 +981,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, updatedAt: event.occurredAt, }); - if ( - event.type !== "thread.message-sent" || - event.payload.role !== "assistant" || - !event.payload.streaming - ) { + if (shouldRefreshThreadShellSummary(event)) { yield* refreshThreadShellSummary(event.payload.threadId); } return; @@ -1736,6 +1759,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const resolvedDecision = resolvedDecisionRaw === "accept" || resolvedDecisionRaw === "acceptForSession" || + resolvedDecisionRaw === "acceptAlways" || resolvedDecisionRaw === "decline" || resolvedDecisionRaw === "cancel" ? resolvedDecisionRaw diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c7f97b4887ae..ce1562647b3b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -83,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, @@ -103,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, @@ -305,6 +307,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + linkedPullRequest: { + projectId: asProjectId("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -427,6 +435,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", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index d737cbff6c3d..6becab78f7fd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -27,6 +27,7 @@ import { ModelSelection, ProjectId, SourceRef, + ThreadLinkedPullRequest, ThreadId, ThreadParticipantSummary, } from "@t3tools/contracts"; @@ -107,6 +108,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( participantSummaries: Schema.NullOr( Schema.fromJsonString(Schema.NullOr(Schema.Array(ThreadParticipantSummary))), ), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -504,6 +506,7 @@ 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", @@ -542,6 +545,7 @@ 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", @@ -582,6 +586,7 @@ 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", @@ -1122,6 +1127,7 @@ 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", @@ -2013,6 +2019,9 @@ 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, @@ -2280,6 +2289,9 @@ 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, @@ -2418,6 +2430,9 @@ 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, @@ -2570,6 +2585,9 @@ 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, @@ -2886,6 +2904,9 @@ 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, @@ -3070,6 +3091,9 @@ 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, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 52a03d02dca9..f0e373c18873 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -179,6 +179,7 @@ describe("ProviderCommandReactor", () => { session: ProviderSession, ) => Effect.Effect; readonly interruptTurnEffect?: ProviderServiceShape["interruptTurn"]; + readonly stopSessionEffect?: () => Effect.Effect; readonly identityPeople?: ReadonlyArray; }) { const now = "2026-01-01T00:00:00.000Z"; @@ -265,23 +266,30 @@ describe("ProviderCommandReactor", () => { turnId: asTurnId("turn-1"), }), ); - const interruptTurn = vi.fn(input?.interruptTurnEffect ?? ((_: unknown) => Effect.void)); + const interruptTurn = vi.fn( + (turnInput: Parameters[0]) => + input?.interruptTurnEffect?.(turnInput) ?? Effect.void, + ); const respondToRequest = vi.fn(() => Effect.void); const respondToUserInput = vi.fn(() => Effect.void); - const stopSession = vi.fn((input: unknown) => - Effect.sync(() => { - const threadId = - typeof input === "object" && input !== null && "threadId" in input - ? (input as { threadId?: ThreadId }).threadId - : undefined; - if (!threadId) { - return; - } - const index = runtimeSessions.findIndex((session) => session.threadId === threadId); - if (index >= 0) { - runtimeSessions.splice(index, 1); - } - }), + const stopSession = vi.fn((stopInput: unknown) => + (input?.stopSessionEffect?.() ?? Effect.void).pipe( + Effect.tap(() => + Effect.sync(() => { + const threadId = + typeof stopInput === "object" && stopInput !== null && "threadId" in stopInput + ? (stopInput as { threadId?: ThreadId }).threadId + : undefined; + if (!threadId) { + return; + } + const index = runtimeSessions.findIndex((session) => session.threadId === threadId); + if (index >= 0) { + runtimeSessions.splice(index, 1); + } + }), + ), + ), ); const renameBranch = vi.fn((input: unknown) => Effect.succeed({ @@ -294,6 +302,11 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); + const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); + const createWorktree = vi.fn( + (input: { readonly refName: string; readonly path: string | null }) => + Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }), + ); const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, @@ -395,6 +408,7 @@ describe("ProviderCommandReactor", () => { }); }, rollbackConversation: () => unsupported(), + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, @@ -462,6 +476,8 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.mock(GitWorkflowService.GitWorkflowService)({ renameBranch, + pruneWorktrees, + createWorktree, } satisfies Partial), ), Layer.provideMerge( @@ -652,6 +668,8 @@ describe("ProviderCommandReactor", () => { respondToUserInput, stopSession, renameBranch, + pruneWorktrees, + createWorktree, refreshStatus, generateBranchName, generateThreadTitle, @@ -2408,6 +2426,50 @@ describe("ProviderCommandReactor", () => { expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); }); + it("recreates a missing worktree from the thread branch before starting a turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const worktreePath = NodePath.join(harness.stateDir, "missing-worktree"); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-missing-worktree"), + threadId: ThreadId.make("thread-1"), + branch: "feature/restore", + worktreePath, + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-worktree"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-worktree"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" }); + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/provider-project", + refName: "feature/restore", + path: worktreePath, + }); + expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( + harness.startSession.mock.invocationCallOrder[0]!, + ); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -3465,6 +3527,218 @@ describe("ProviderCommandReactor", () => { }); }); + effectIt.effect( + "stops a running session and records the failure when provider interrupt fails", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ + interruptTurnEffect: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.interrupt", + detail: "provider session disappeared", + }), + ), + stopSessionEffect: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "session.stop", + detail: "provider process already exited", + }), + ), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-interrupt-failure"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-provider-failure"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-1"), + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "stopped"; + }), + ); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "stopped", + activeTurnId: null, + lastError: "provider session disappeared", + }); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.interrupt.failed"), + ).toMatchObject({ + summary: "Provider turn interrupt failed", + payload: { detail: "provider session disappeared" }, + }); + expect(harness.stopSession).toHaveBeenCalledWith({ threadId: ThreadId.make("thread-1") }); + }), + ); + + effectIt.effect("stops a starting session without a bound turn when interrupt fails", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ + interruptTurnEffect: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.interrupt", + detail: "provider session disappeared", + }), + ), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-interrupt-starting"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-starting-provider-failure"), + threadId: ThreadId.make("thread-1"), + createdAt: now, + }); + + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "stopped", + activeTurnId: null, + lastError: "provider session disappeared", + }); + expect(harness.stopSession).toHaveBeenCalledWith({ threadId: ThreadId.make("thread-1") }); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.interrupt.failed"), + ).toMatchObject({ payload: { detail: "provider session disappeared" } }); + }), + ); + + effectIt.effect("does not overwrite a session that became ready while an interrupt failed", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + const completedAt = "2026-01-01T00:00:01.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-interrupt-race"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + harness.interruptTurn.mockImplementation(() => + harness.engine + .dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-natural-completion"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: completedAt, + }, + createdAt: completedAt, + }) + .pipe( + Effect.catchCause((cause) => Effect.die(cause)), + Effect.andThen( + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.interrupt", + detail: "provider session disappeared", + }), + ), + ), + ), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-race"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-1"), + createdAt: now, + }); + + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "ready", + activeTurnId: null, + lastError: null, + updatedAt: completedAt, + }); + expect(harness.stopSession).not.toHaveBeenCalled(); + expect( + thread?.activities.some((activity) => activity.kind === "provider.turn.interrupt.failed"), + ).toBe(false); + }), + ); + it("starts a fresh session when only projected session state exists", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 8742bb05d025..933434e844b7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -22,6 +22,7 @@ import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; 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 Schema from "effect/Schema"; @@ -347,6 +348,7 @@ const make = Effect.gen(function* () { const providerSessionDirectory = yield* ProviderSessionDirectory; const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; + const fileSystem = yield* FileSystem.FileSystem; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; @@ -546,6 +548,52 @@ const make = Effect.gen(function* () { .pipe(Effect.map(Option.getOrUndefined)); }); + /** + * Recreates a thread's worktree from its branch when the directory has + * disappeared. Provider sessions resume into the persisted cwd, so a missing + * worktree makes every later turn fail as a bogus "session not found". + * Best-effort: on failure the turn proceeds and reports the real error. + */ + const ensureThreadWorktree = Effect.fnUntraced(function* (thread: { + readonly id: ThreadId; + readonly projectId: ProjectId; + readonly branch: string | null; + readonly worktreePath: string | null; + }) { + const { worktreePath, branch } = thread; + if (!worktreePath || !branch) { + return; + } + const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true)); + if (exists) { + return; + } + const project = yield* resolveProject(thread.projectId); + if (!project) { + return; + } + const cwd = project.workspaceRoot; + yield* Effect.logWarning("provider command reactor recreating missing worktree", { + threadId: thread.id, + worktreePath, + branch, + }); + // A directory deleted without `git worktree remove` leaves an admin entry + // that makes `git worktree add` refuse the path; prune clears it. + yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( + Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("provider command reactor failed to recreate worktree", { + threadId: thread.id, + worktreePath, + cause: Cause.pretty(cause), + }), + ), + ); + }); + const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery .getThreadDetailById(threadId) @@ -1233,6 +1281,8 @@ const make = Effect.gen(function* () { return; } + yield* ensureThreadWorktree(thread); + const isFirstUserMessageTurn = thread.messages.filter((entry) => entry.role === "user").length === 1; if (isFirstUserMessageTurn) { @@ -1337,8 +1387,8 @@ const make = Effect.gen(function* () { if (!thread) { return; } - const hasSession = thread.session && thread.session.status !== "stopped"; - if (!hasSession) { + const session = thread.session; + if (!session || session.status === "stopped") { return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.interrupt.failed", @@ -1349,13 +1399,84 @@ const make = Effect.gen(function* () { }); } + const recoverInterruptFailure = (cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + + const detail = formatFailureDetail(cause); + return Effect.gen(function* () { + const latestThread = yield* resolveThread(event.payload.threadId); + const latestSession = latestThread?.session; + if ( + !latestSession || + latestSession.status === "stopped" || + (latestSession.status === "ready" && latestSession.updatedAt > event.payload.createdAt) || + (event.payload.turnId !== undefined && + latestSession.activeTurnId !== null && + latestSession.activeTurnId !== event.payload.turnId) + ) { + return; + } + + yield* providerService.stopSession({ threadId: event.payload.threadId }).pipe( + Effect.catchCause((stopCause) => { + if (Cause.hasInterruptsOnly(stopCause)) { + return Effect.interrupt; + } + return Effect.logWarning( + "provider command reactor failed to stop session after interrupt failure", + { + threadId: event.payload.threadId, + cause: Cause.pretty(stopCause), + originalCause: Cause.pretty(cause), + }, + ); + }), + ); + const stoppedThread = yield* resolveThread(event.payload.threadId); + const stoppedSession = stoppedThread?.session; + if ( + !stoppedSession || + stoppedSession.status === "stopped" || + (stoppedSession.status === "ready" && + stoppedSession.updatedAt > event.payload.createdAt) || + (event.payload.turnId !== undefined && + stoppedSession.activeTurnId !== null && + stoppedSession.activeTurnId !== event.payload.turnId) + ) { + return; + } + + yield* setThreadSession({ + threadId: event.payload.threadId, + session: { + ...stoppedSession, + status: "stopped", + activeTurnId: null, + lastError: detail, + updatedAt: event.payload.createdAt, + }, + createdAt: event.payload.createdAt, + }); + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.interrupt.failed", + summary: "Provider turn interrupt failed", + detail, + turnId: event.payload.turnId ?? null, + createdAt: event.payload.createdAt, + }); + }); + }; + // Orchestration turn ids are not provider turn ids, so interrupt by session. // Clear the projection before touching the provider. This state transition // is authoritative and must not depend on a cooperative protocol peer. yield* setThreadSession({ threadId: event.payload.threadId, session: { - ...thread.session, + ...session, status: "ready", activeTurnId: null, updatedAt: event.payload.createdAt, @@ -1365,17 +1486,15 @@ const make = Effect.gen(function* () { // Provider cancellation is best-effort and bounded. Some protocol peers // never answer cancellation; an interruptible timeout releases this - // thread's command lane even in that case. + // thread's command lane even in that case. Failures recover by stopping + // the session; a hang times out after the projection is already ready. const interruptResult = yield* providerService .interruptTurn({ threadId: event.payload.threadId }) .pipe( Effect.interruptible, Effect.timeoutOption(PROVIDER_CONTROL_TIMEOUT), Effect.catchCause((cause) => - Effect.logWarning("provider turn interrupt failed", { - threadId: event.payload.threadId, - cause: Cause.pretty(cause), - }).pipe(Effect.as(Option.some(undefined))), + recoverInterruptFailure(cause).pipe(Effect.as(Option.some(undefined))), ), ); if (Option.isNone(interruptResult)) { @@ -1383,6 +1502,16 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, timeout: Duration.format(PROVIDER_CONTROL_TIMEOUT), }); + yield* setThreadSession({ + threadId: event.payload.threadId, + session: { + ...session, + status: "ready", + activeTurnId: null, + updatedAt: event.payload.createdAt, + }, + createdAt: event.payload.createdAt, + }); } }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts index 05370781c0d0..0d262028dedf 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts @@ -30,4 +30,41 @@ describe("runtimeEventToActivities approval details", () => { expect(activity?.kind).toBe("approval.requested"); expect((activity?.payload as Record | undefined)?.detail).toBe(detail); }); + + it("keeps app details and approval options available to remote clients", () => { + const options = [ + { decision: "decline", label: "Decline" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ] as const; + const event = { + type: "request.opened", + eventId: EventId.make("evt-mcp-elicitation"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-08-24T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + requestId: RuntimeRequestId.make("approval-safari"), + payload: { + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + } satisfies ProviderRuntimeEvent; + + const [activity] = runtimeEventToActivities(event); + + expect(activity).toMatchObject({ + kind: "approval.requested", + summary: "App access approval requested", + payload: { + requestId: "approval-safari", + requestKind: "mcp-elicitation", + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts index 2f168c5ccf6c..02f65212cce8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts @@ -97,6 +97,7 @@ function createProviderServiceHarness() { }); }, rollbackConversation: () => unsupported(), + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 0803a6e21d48..c785027aab3b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -126,6 +126,7 @@ function createProviderServiceHarness() { }); }, rollbackConversation: () => unsupported(), + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a0f58c18f444..e4e456cec018 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -336,7 +336,7 @@ function sessionStatusAllowsActiveTurn( function requestKindFromCanonicalRequestType( requestType: string | undefined, -): "command" | "file-read" | "file-change" | undefined { +): "command" | "file-read" | "file-change" | "mcp-elicitation" | undefined { switch (requestType) { case "command_execution_approval": case "exec_command_approval": @@ -346,6 +346,8 @@ function requestKindFromCanonicalRequestType( case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "mcp_elicitation_approval": + return "mcp-elicitation"; default: return undefined; } @@ -426,12 +428,16 @@ export function runtimeEventToActivities( ? "File-read approval requested" : requestKind === "file-change" ? "File-change approval requested" - : "Approval requested", + : requestKind === "mcp-elicitation" + ? "App access approval requested" + : "Approval requested", payload: { requestId: toApprovalRequestId(event.requestId), ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.detail ? { detail: event.payload.detail } : {}), + ...(event.payload.appName ? { appName: event.payload.appName } : {}), + ...(event.payload.options ? { options: event.payload.options } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/orchestration/Normalizer.attachments.test.ts b/apps/server/src/orchestration/Normalizer.attachments.test.ts new file mode 100644 index 000000000000..27a35977ffca --- /dev/null +++ b/apps/server/src/orchestration/Normalizer.attachments.test.ts @@ -0,0 +1,318 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + type ClientOrchestrationCommand, + CommandId, + MessageId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { cleanupFailedUploadedAttachments, normalizeDispatchCommand } from "./Normalizer.ts"; + +const testLayer = Layer.mergeAll( + WorkspacePaths.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-normalizer-attachments-" }), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const attachmentUuid = "00000000-0000-4000-8000-0000000000aa"; + +function turnStartCommand(input: { + readonly threadId?: string; + readonly attachments: ReadonlyArray< + | { readonly id: string; readonly sizeBytes: number } + | { readonly dataUrl: string; readonly sizeBytes: number } + >; +}): ClientOrchestrationCommand { + return { + type: "thread.turn.start", + commandId: CommandId.make("command-1"), + threadId: ThreadId.make(input.threadId ?? "thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "look at this", + attachments: input.attachments.map((attachment) => ({ + type: "image" as const, + name: "screenshot.png", + mimeType: "image/png", + ...attachment, + })), + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-08-01T00:00:00.000Z", + }; +} + +describe("normalizeDispatchCommand attachments", () => { + it.effect("preserves inline image attachments from existing mobile clients", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const normalized = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ dataUrl: "data:image/png;base64,cGl4ZWxz", 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.id.startsWith("thread-1-")).toBe(true); + expect( + NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${attachment.id}.png`)), + ).toEqual(Buffer.from("pixels")); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("claims uploaded attachments while retaining a retryable pending copy", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const bytes = Buffer.from("pixels"); + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, bytes); + + const normalized = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], + }), + ); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const attachmentId = normalized.message.attachments[0]!.id; + 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, + ); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("normalizes inline and uploaded attachments in the same turn", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), + Buffer.from("pixels"), + ); + + const normalized = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [ + { dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }, + { id: `pending-${attachmentUuid}`, sizeBytes: 6 }, + ], + }), + ); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + expect(normalized.message.attachments).toHaveLength(2); + expect(normalized.message.attachments[1]?.id.startsWith("thread-1-")).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("retries a failed bootstrap with a fresh thread id", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const bytes = Buffer.from("pixels"); + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), + bytes, + ); + + const first = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], + }), + ); + if (first.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + NodeFS.rmSync( + NodePath.join(config.attachmentsDir, `${first.message.attachments[0]!.id}.png`), + ); + + const retried = yield* normalizeDispatchCommand( + turnStartCommand({ + threadId: "thread-retry", + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], + }), + ); + if (retried.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + expect(retried.message.attachments[0]?.id.startsWith("thread-retry-")).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes failed attachment claims without deleting their pending uploads", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + const command = turnStartCommand({ + attachments: [ + { dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }, + { id: `pending-${attachmentUuid}`, sizeBytes: 6 }, + ], + }); + const normalized = yield* normalizeDispatchCommand(command); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const inlinePath = NodePath.join( + config.attachmentsDir, + `${normalized.message.attachments[0]!.id}.png`, + ); + const claimedPath = NodePath.join( + config.attachmentsDir, + `${normalized.message.attachments[1]!.id}.png`, + ); + yield* cleanupFailedUploadedAttachments(command, normalized); + + expect(NodeFS.existsSync(pendingPath)).toBe(true); + expect(NodeFS.existsSync(claimedPath)).toBe(false); + expect(NodeFS.existsSync(inlinePath)).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes a failed claimed copy after its pending original was removed", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + const command = turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], + }); + const normalized = yield* normalizeDispatchCommand(command); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const claimedPath = NodePath.join( + config.attachmentsDir, + `${normalized.message.attachments[0]!.id}.png`, + ); + NodeFS.rmSync(pendingPath); + + yield* cleanupFailedUploadedAttachments(command, normalized); + + expect(NodeFS.existsSync(claimedPath)).toBe(false); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps concurrent claims independent when one dispatch fails", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + const command = turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], + }); + + const [failed, succeeded] = yield* Effect.all( + [normalizeDispatchCommand(command), normalizeDispatchCommand(command)], + { concurrency: 2 }, + ); + if (failed.type !== "thread.turn.start" || succeeded.type !== "thread.turn.start") { + throw new Error("Expected thread.turn.start commands."); + } + + const failedPath = NodePath.join( + config.attachmentsDir, + `${failed.message.attachments[0]!.id}.png`, + ); + const succeededPath = NodePath.join( + config.attachmentsDir, + `${succeeded.message.attachments[0]!.id}.png`, + ); + expect(failedPath).not.toBe(succeededPath); + + yield* cleanupFailedUploadedAttachments(command, failed); + + expect(NodeFS.existsSync(pendingPath)).toBe(true); + expect(NodeFS.existsSync(failedPath)).toBe(false); + expect(NodeFS.existsSync(succeededPath)).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes earlier claimed copies when a later attachment cannot be normalized", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingId = `pending-${attachmentUuid}`; + const pendingPath = NodePath.join(config.attachmentsDir, `${pendingId}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + + const failure = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [ + { id: pendingId, sizeBytes: 6 }, + { + id: "pending-00000000-0000-4000-8000-0000000000ff", + sizeBytes: 6, + }, + ], + }), + ).pipe(Effect.flip); + + expect(failure.message).toContain("not found"); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${pendingId}.png`]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects uploaded attachments with the wrong size or thread", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), + Buffer.from("pixels"), + ); + + const wrongSize = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 999 }], + }), + ).pipe(Effect.flip); + expect(wrongSize.message).toContain("size"); + + const wrongThread = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `another-thread-${attachmentUuid}`, sizeBytes: 6 }], + }), + ).pipe(Effect.flip); + expect(wrongThread.message).toContain("pending upload"); + + const mismatchedTypeCommand = turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], + }); + if (mismatchedTypeCommand.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + const mismatchedType = yield* normalizeDispatchCommand({ + ...mismatchedTypeCommand, + message: { + ...mismatchedTypeCommand.message, + attachments: mismatchedTypeCommand.message.attachments.map((attachment) => ({ + ...attachment, + mimeType: "image/jpeg", + })), + }, + }).pipe(Effect.flip); + expect(mismatchedType.message).toContain("image type"); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 24c65900b296..bd6a8f242b87 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -10,7 +10,13 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; +import { + createAttachmentId, + planAttachmentClaim, + PENDING_ATTACHMENT_THREAD_SEGMENT, + parseThreadSegmentFromAttachmentId, + resolveAttachmentPath, +} from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; import { parseBase64DataUrl } from "../imageMime.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -43,6 +49,29 @@ export const canonicalizeClientCommandTimestamps = ( }; }; +const removeClaimedAttachmentPaths = Effect.fn("Normalizer.removeClaimedAttachmentPaths")( + function* (attachmentPaths: ReadonlyArray) { + if (attachmentPaths.length === 0) { + return; + } + const fileSystem = yield* FileSystem.FileSystem; + yield* Effect.forEach( + attachmentPaths, + (attachmentPath) => + fileSystem.remove(attachmentPath, { force: true }).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to remove an unclaimed attachment copy.", { + attachmentPath, + cause, + }), + ), + Effect.orElseSucceed(() => undefined), + ), + { concurrency: 1 }, + ); + }, +); + export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { const receivedAt = DateTime.formatIso(yield* DateTime.now); @@ -104,10 +133,69 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => return canonicalCommand as OrchestrationCommand; } + const claimedAttachmentPaths: string[] = []; const normalizedAttachments = yield* Effect.forEach( canonicalCommand.message.attachments, (attachment) => Effect.gen(function* () { + if (!("dataUrl" in attachment)) { + const claim = planAttachmentClaim({ + attachmentsDir: serverConfig.attachmentsDir, + threadId: canonicalCommand.threadId, + attachmentId: attachment.id, + }); + if (!claim.ok) { + return yield* new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: ${claim.reason}.`, + }); + } + + const info = yield* fileSystem.stat(claim.currentPath).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: attachment not found.`, + cause, + }), + ), + ); + if (Number(info.size) !== attachment.sizeBytes) { + return yield* new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: stored size does not match.`, + }); + } + + const normalizedAttachment = { + ...attachment, + id: claim.finalId, + mimeType: attachment.mimeType.toLowerCase(), + }; + const expectedPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment: normalizedAttachment, + }); + if (expectedPath !== claim.finalPath) { + return yield* new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: image 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. + yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: `Failed to claim attachment '${attachment.name}' for this thread.`, + cause, + }), + ), + ); + claimedAttachmentPaths.push(claim.finalPath); + + return normalizedAttachment; + } + const parsed = parseBase64DataUrl(attachment.dataUrl); if (!parsed || !parsed.mimeType.startsWith("image/")) { return yield* new OrchestrationDispatchCommandError({ @@ -167,7 +255,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => return persistedAttachment; }), { concurrency: 1 }, - ); + ).pipe(Effect.tapError(() => removeClaimedAttachmentPaths(claimedAttachmentPaths))); return { ...canonicalCommand, @@ -177,3 +265,33 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }, } satisfies OrchestrationCommand; }); + +export const cleanupFailedUploadedAttachments = Effect.fn( + "Normalizer.cleanupFailedUploadedAttachments", +)(function* (command: ClientOrchestrationCommand, normalizedCommand: OrchestrationCommand) { + if (command.type !== "thread.turn.start" || normalizedCommand.type !== "thread.turn.start") { + return; + } + + const serverConfig = yield* ServerConfig; + const claimedPaths: string[] = []; + for (const [index, attachment] of normalizedCommand.message.attachments.entries()) { + const original = command.message.attachments[index]; + if ( + !original || + "dataUrl" in original || + parseThreadSegmentFromAttachmentId(original.id) !== PENDING_ATTACHMENT_THREAD_SEGMENT + ) { + continue; + } + + const claimedPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (claimedPath) { + claimedPaths.push(claimedPath); + } + } + yield* removeClaimedAttachmentPaths(claimedPaths); +}); diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index b224887e465f..43b1f2686e7a 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -10,7 +10,11 @@ * * @module OrchestrationEngineService */ -import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts"; +import type { + OrchestrationClientOrigin, + OrchestrationCommand, + OrchestrationEvent, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; @@ -40,6 +44,8 @@ export interface OrchestrationEngineShape { * Dispatch a validated orchestration command. * * @param command - Valid orchestration command. + * @param options - Optional client origin (surface/app version) stamped into + * the metadata of every event the command produces. * @returns Effect containing the sequence of the persisted event. * * Dispatch is serialized through an internal queue and deduplicated via @@ -47,6 +53,7 @@ export interface OrchestrationEngineShape { */ readonly dispatch: ( command: OrchestrationCommand, + options?: { readonly origin?: OrchestrationClientOrigin }, ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; /** diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 64a0d687b36d..5f27c945a662 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1074,6 +1074,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(branch !== undefined ? { branch } : {}), ...(nextWorktreePath !== undefined ? { worktreePath: nextWorktreePath } : {}), + ...(command.linkedPullRequest !== undefined + ? { linkedPullRequest: command.linkedPullRequest } + : {}), updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index ead58a1b9679..a1ed2c6a6d73 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts"; -import { normalizeDispatchCommand } from "./Normalizer.ts"; +import { cleanupFailedUploadedAttachments, normalizeDispatchCommand } from "./Normalizer.ts"; import { annotateEnvironmentRequest, failEnvironmentInternal, @@ -145,13 +145,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( clientDeviceType, people: mapPeople, }); - return yield* orchestrationEngine - .dispatch(normalizedCommand) - .pipe( - Effect.catch((cause) => - failEnvironmentInternal("orchestration_dispatch_failed", cause), - ), - ); + return yield* orchestrationEngine.dispatch(normalizedCommand).pipe( + Effect.tapError(() => + cleanupFailedUploadedAttachments(args.payload, normalizedCommand), + ), + Effect.catch((cause) => + failEnvironmentInternal("orchestration_dispatch_failed", cause), + ), + ); }), ); }), diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index d05890c7cca5..0a8bfc49a6ea 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -482,6 +482,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/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 545688e38228..579d3a608190 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -10,6 +10,7 @@ import { AuthClientMetadataDeviceType, AuthEnvironmentScopes, AuthSessionId, + ClientSurface, ServerAuthSessionMethod, } from "@t3tools/contracts"; @@ -82,6 +83,13 @@ export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ }); export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; +export const SetAuthSessionClientConnectionInput = Schema.Struct({ + sessionId: AuthSessionId, + surface: Schema.NullOr(ClientSurface), + appVersion: Schema.NullOr(Schema.String), +}); +export type SetAuthSessionClientConnectionInput = typeof SetAuthSessionClientConnectionInput.Type; + export class AuthSessionRepository extends Context.Service< AuthSessionRepository, { @@ -103,6 +111,9 @@ export class AuthSessionRepository extends Context.Service< readonly setLastConnectedAt: ( input: SetAuthSessionLastConnectedAtInput, ) => Effect.Effect; + readonly setClientConnection: ( + input: SetAuthSessionClientConnectionInput, + ) => Effect.Effect; } >()("t3/persistence/AuthSessions/AuthSessionRepository") {} @@ -281,6 +292,20 @@ export const make = Effect.gen(function* () { `, }); + // COALESCE keeps the previous value when a client reports only one field, so + // a partial report never nulls out data a fuller client stored earlier. + const setClientConnectionRow = SqlSchema.void({ + Request: SetAuthSessionClientConnectionInput, + execute: ({ sessionId, surface, appVersion }) => + sql` + UPDATE auth_sessions + SET client_surface = COALESCE(${surface}, client_surface), + client_app_version = COALESCE(${appVersion}, client_app_version) + WHERE session_id = ${sessionId} + AND revoked_at IS NULL + `, + }); + const revokeSessionRows = SqlSchema.findAll({ Request: RevokeAuthSessionInput, Result: Schema.Struct({ sessionId: AuthSessionId }), @@ -404,6 +429,17 @@ export const make = Effect.gen(function* () { ), ); + const setClientConnection: AuthSessionRepository["Service"]["setClientConnection"] = (input) => + setClientConnectionRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.setClientConnection:query", + "AuthSessionRepository.setClientConnection:encodeRequest", + { sessionId: input.sessionId }, + ), + ), + ); + return { create, getById, @@ -411,6 +447,7 @@ export const make = Effect.gen(function* () { revoke, revokeAllExcept, setLastConnectedAt, + setClientConnection, } satisfies AuthSessionRepository["Service"]; }); diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index c725fefe2e07..a5cd0a55e6cd 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -283,4 +283,55 @@ projectionRepositoriesLayer("Projection repositories", (it) => { ); }), ); + + 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, + 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/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 71bc9266e385..06110dbef46f 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -16,7 +16,12 @@ import { ProjectionThreadWorktreeReference, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection, SourceRef, ThreadParticipantSummary } from "@t3tools/contracts"; +import { + ModelSelection, + SourceRef, + ThreadLinkedPullRequest, + ThreadParticipantSummary, +} from "@t3tools/contracts"; // JSON columns may be SQL NULL or the string "null" (from JSON.stringify(null)). // Decode with NullOr inside fromJsonString so both forms become null. @@ -27,6 +32,7 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields( participantSummaries: Schema.NullOr( Schema.fromJsonString(Schema.NullOr(Schema.Array(ThreadParticipantSummary))), ), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -41,6 +47,7 @@ function toProjectionThread(row: ProjectionThreadDbRow): ProjectionThread { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + linkedPullRequest: row.linkedPullRequest ?? null, latestTurnId: row.latestTurnId, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -82,6 +89,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + linked_pull_request_json, latest_turn_id, created_at, updated_at, @@ -111,6 +119,7 @@ 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}, @@ -140,6 +149,7 @@ 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, @@ -182,6 +192,7 @@ 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", @@ -220,6 +231,7 @@ 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", diff --git a/apps/server/src/persistence/MigrationNamespaces.test.ts b/apps/server/src/persistence/MigrationNamespaces.test.ts index 7b5e218e4fc9..bb977aacbfbd 100644 --- a/apps/server/src/persistence/MigrationNamespaces.test.ts +++ b/apps/server/src/persistence/MigrationNamespaces.test.ts @@ -8,10 +8,10 @@ describe("migration namespaces", () => { it("keeps upstream and fork manifests in independent ledgers", () => { assert.notEqual(upstreamMigrationTable, forkMigrationTable); assert.deepStrictEqual(migrationManifest.slice(-4), [ - [37, "ProjectionTurnsKeysetIndex"], - [38, "ProjectionThreadsPinOrderKey"], [39, "ProjectionProjectsDefaultThreadEnvMode"], [40, "ProjectionProjectFaviconPath"], + [41, "AuthSessionClientConnection"], + [42, "ProjectionThreadLinkedPullRequest"], ]); assert.deepStrictEqual(forkMigrationManifest, [ [1, "ProjectionQueuedMessages"], diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c9ff2977cf9f..9a7553a0898e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -56,6 +56,8 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; +import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; /** * Migration loader with all migrations defined inline. @@ -108,6 +110,8 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "AuthSessionClientConnection", Migration0041], + [42, "ProjectionThreadLinkedPullRequest", Migration0042], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts index 55163a43fa65..cee0658f14d3 100644 --- a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts +++ b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts @@ -87,10 +87,10 @@ layer("b18 desktop migration namespace repair", (it) => { readonly name: string; }>`SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id`; assert.deepStrictEqual(upstreamMigrations.slice(-4), [ - { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, - { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, { migration_id: 40, name: "ProjectionProjectFaviconPath" }, + { migration_id: 41, name: "AuthSessionClientConnection" }, + { migration_id: 42, name: "ProjectionThreadLinkedPullRequest" }, ]); const forkMigrations = yield* sql<{ diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts new file mode 100644 index 000000000000..178338b78318 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts @@ -0,0 +1,31 @@ +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("041_AuthSessionClientConnection", (it) => { + it.effect("adds nullable client surface and app version columns to auth sessions", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 40 }); + yield* runMigrations({ toMigrationInclusive: 41 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(auth_sessions) + `; + const surface = columns.find((column) => column.name === "client_surface"); + const appVersion = columns.find((column) => column.name === "client_app_version"); + + assert.equal(surface?.name, "client_surface"); + assert.equal(surface?.notnull, 0); + assert.equal(appVersion?.name, "client_app_version"); + assert.equal(appVersion?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts new file mode 100644 index 000000000000..2194c3cd0f14 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// Client-declared surface (web/desktop/mobile) and app version, refreshed on +// every WebSocket connect so the row tracks the client's current build instead +// of freezing at session issuance. Nullable: old clients never report them. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + + if (!columns.some((column) => column.name === "client_surface")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_surface TEXT + `; + } + + if (!columns.some((column) => column.name === "client_app_version")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_app_version TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts new file mode 100644 index 000000000000..1fe59df50729 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts @@ -0,0 +1,25 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("042_ProjectionThreadLinkedPullRequest", (it) => { + it.effect("adds the linked pull request column", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 41 }); + yield* runMigrations({ toMigrationInclusive: 42 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.ok(columns.some((column) => column.name === "linked_pull_request_json")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts new file mode 100644 index 000000000000..a026f39c392a --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "linked_pull_request_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN linked_pull_request_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts index f7716ca7508b..956eaeed901e 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts @@ -39,10 +39,10 @@ layer("fork migration namespace for a repaired database", (it) => { SELECT migration_id, name FROM ${sql(legacyMigrationBackupTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-4), [ - { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, - { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, { migration_id: 40, name: "ProjectionProjectFaviconPath" }, + { migration_id: 41, name: "AuthSessionClientConnection" }, + { migration_id: 42, name: "ProjectionThreadLinkedPullRequest" }, ]); assert.deepStrictEqual(fork, [ { migration_id: 1, name: "ProjectionQueuedMessages" }, diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts index 674fdbfe53eb..d64012b5fef7 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts @@ -72,10 +72,10 @@ layer("smart migration namespace repair", (it) => { SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-4), [ - { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, - { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, { migration_id: 40, name: "ProjectionProjectFaviconPath" }, + { migration_id: 41, name: "AuthSessionClientConnection" }, + { migration_id: 42, name: "ProjectionThreadLinkedPullRequest" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts index a4c0997d6eaf..7db388c06232 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts @@ -29,12 +29,12 @@ layer("t3vm migration namespace repair", (it) => { SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-6), [ - { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, - { migration_id: 36, name: "ProjectionThreadsPinned" }, { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, { migration_id: 40, name: "ProjectionProjectFaviconPath" }, + { migration_id: 41, name: "AuthSessionClientConnection" }, + { migration_id: 42, name: "ProjectionThreadLinkedPullRequest" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index b3f1ab230640..545ed1e87f94 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -15,6 +15,7 @@ import { ProviderInteractionMode, RuntimeMode, SourceRef, + ThreadLinkedPullRequest, ThreadId, ThreadParticipantSummary, TurnId, @@ -35,6 +36,7 @@ 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, diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 7448ced247b5..c610781ea9be 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -91,6 +91,21 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("uses a saved project favicon outside the workspace", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + const pictures = yield* makeTempDir; + yield* writeTextFile(pictures, "custom.png", "image"); + const externalPath = path.join(pictures, "custom.png"); + + const resolved = yield* resolver.resolvePath(cwd, externalPath); + + expect(resolved).toBe(externalPath); + }), + ); + it.effect("falls back when a saved override is missing from a checkout", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 458954daed4b..9d9a5bddc791 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -137,22 +137,25 @@ export const make = Effect.gen(function* () { const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, relativeCandidates: ReadonlyArray, + candidateScope: "workspace" | "filesystem", ): Effect.fn.Return { for (const relativePath of relativeCandidates) { - const candidate = yield* workspacePaths - .resolveRelativePathWithinRoot({ - workspaceRoot: projectCwd, - relativePath, - }) - .pipe( - Effect.map(Option.some), - Effect.catchTags({ - WorkspacePathOutsideRootError: () => - Effect.succeed( - Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), - ), - }), - ); + const candidate = yield* ( + candidateScope === "filesystem" && path.isAbsolute(relativePath) + ? Effect.succeed({ absolutePath: relativePath, relativePath }) + : workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath, + }) + ).pipe( + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => + Effect.succeed( + Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), + ), + }), + ); if (Option.isNone(candidate)) { continue; } @@ -191,7 +194,7 @@ export const make = Effect.gen(function* () { // A grouped project's saved path can be absent from one checkout. Use it // where it exists and retain automatic discovery for the other checkouts. if (faviconPath !== undefined) { - const existing = yield* findExistingFile(projectCwd, [faviconPath]); + const existing = yield* findExistingFile(projectCwd, [faviconPath], "filesystem"); if (existing) { return existing; } @@ -200,14 +203,18 @@ export const make = Effect.gen(function* () { // A t3.json iconPath takes precedence over the well-known locations. const projectFile = yield* projectFileLoader.load(projectCwd); if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { - const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]); + const existing = yield* findExistingFile( + projectCwd, + [projectFile.value.iconPath], + "workspace", + ); if (existing) { return existing; } } for (const candidate of FAVICON_CANDIDATES) { - const existing = yield* findExistingFile(projectCwd, [candidate]); + const existing = yield* findExistingFile(projectCwd, [candidate], "workspace"); if (existing) { return existing; } @@ -251,7 +258,7 @@ export const make = Effect.gen(function* () { if (!href) { continue; } - const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); + const existing = yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace"); if (existing) { return existing; } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 689f2b477629..9440e928e388 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -54,12 +54,11 @@ class FakeClaudeQuery implements AsyncIterable { private done = false; private failure: unknown | undefined; - public readonly interruptCalls: Array = []; - public readonly stopTaskCalls: Array = []; public readonly setModelCalls: Array = []; public readonly setPermissionModeCalls: Array = []; public readonly setMaxThinkingTokensCalls: Array = []; public closeCalls = 0; + public closeError: unknown | undefined; emit(message: SDKMessage): void { if (this.done) { @@ -95,14 +94,6 @@ class FakeClaudeQuery implements AsyncIterable { } } - readonly interrupt = async (): Promise => { - this.interruptCalls.push(undefined); - }; - - readonly stopTask = async (taskId: string): Promise => { - this.stopTaskCalls.push(taskId); - }; - readonly setModel = async (model?: string): Promise => { this.setModelCalls.push(model); }; @@ -117,6 +108,9 @@ class FakeClaudeQuery implements AsyncIterable { readonly close = (): void => { this.closeCalls += 1; + if (this.closeError !== undefined) { + throw this.closeError; + } this.finish(); }; @@ -459,6 +453,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* () { @@ -776,6 +789,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({ @@ -1620,7 +1666,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("interruptTurn settles every acknowledged live task before interrupting", () => { + it.effect("interruptTurn settles live tasks and closes the provider session", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -1685,9 +1731,12 @@ describe("ClaudeAdapterLive", () => { ); yield* adapter.interruptTurn(session.threadId); - // Only the still-live task is stopped; interrupt always fires after. - assert.deepEqual(harness.query.stopTaskCalls, ["task-live"]); - assert.equal(harness.query.interruptCalls.length, 1); + // Closing the session is the hard stop because SDK interrupt can leave + // resumed background work alive. + assert.equal(harness.query.closeCalls, 1); + + const sessions = yield* adapter.listSessions(); + assert.equal(sessions.length, 0); const stoppedTaskEvents = Array.from(yield* Fiber.join(stoppedTaskEventFiber)); assert.equal(stoppedTaskEvents.length, 1); @@ -1705,6 +1754,172 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps the session available when process close fails", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + harness.query.closeError = new Error("close failed"); + + const result = yield* adapter.interruptTurn(session.threadId).pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "ProviderAdapterProcessError"); + } + assert.equal(harness.query.closeCalls, 1); + assert.equal(yield* adapter.hasSession(session.threadId), true); + assert.equal((yield* adapter.listSessions())[0]?.status, "ready"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("stopAll attempts every session when one process close fails", () => { + const queries: FakeClaudeQuery[] = []; + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: () => { + const query = new FakeClaudeQuery(); + 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; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const firstQuery = queries[0]; + if (!firstQuery) { + return; + } + firstQuery.closeError = new Error("close failed"); + + const result = yield* adapter.stopAll().pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.equal(queries[0]?.closeCalls, 1); + assert.equal(queries[1]?.closeCalls, 1); + assert.equal(yield* adapter.hasSession(THREAD_ID), true); + assert.equal(yield* adapter.hasSession(RESUME_THREAD_ID), false); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + + 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; + }); + 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( + Stream.runCollect, + Effect.forkChild, + ); + const firstSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: firstSession.threadId, + input: "hello", + attachments: [], + }); + + const interruptFiber = yield* adapter + .interruptTurn(firstSession.threadId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => usageStarted); + assert.equal(queries[0]?.closeCalls, 1); + + const replacement = 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); + + 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", + ], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("workflow member coalescing: identical snapshots suppress, changes emit", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1874,6 +2089,84 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("a subagent snapshot that beats task_started still wins over the seed", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const taskEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type.startsWith("task.")), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "max" }], + ), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "spawn an agent", + attachments: [], + }); + + // The subagent streams its first assistant snapshot before the task is + // registered, so there is no agent to refine yet. + harness.query.emit({ + type: "assistant", + parent_tool_use_id: "toolu_agent_early", + message: { + model: "claude-sonnet-5[1m]", + content: [], + }, + uuid: "early-snapshot-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-early", + description: "Agent E", + task_type: "local_agent", + tool_use_id: "toolu_agent_early", + uuid: "task-early-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-early", + description: "Agent E", + usage: { total_tokens: 100, tool_uses: 1, duration_ms: 10 }, + uuid: "task-early-progress-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + + const taskEvents = Array.from(yield* Fiber.join(taskEventsFiber)); + const started = taskEvents[0]; + assert.equal(started?.type, "task.started"); + if (started?.type === "task.started") { + assert.equal(started.payload.model, "claude-sonnet-5[1m]"); + assert.equal(started.payload.effort, "max"); + } + const progress = taskEvents[1]; + assert.equal(progress?.type, "task.progress"); + if (progress?.type === "task.progress") { + assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -4207,6 +4500,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* () { @@ -4499,6 +4848,74 @@ 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", + requestId: "req-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 40655d5d7783..ec3364b84e6b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -57,6 +57,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"; @@ -242,6 +246,32 @@ interface ClaudeTaskAgentState { effort: string | undefined; } +/** + * How many racing snapshot models to buffer per session. A snapshot whose + * task_started never arrives would otherwise pin its entry for the session's + * lifetime; oldest entries evict first. + */ +const PENDING_TASK_MODEL_CAP = 64; + +/** + * Buffers a subagent snapshot's authoritative model under its + * parent_tool_use_id, for snapshots that beat their task_started to the + * stream. task_started consumes the entry when it registers the task. + */ +function rememberPendingTaskModel( + pending: Map, + parentToolUseId: string, + model: string, +): void { + pending.set(parentToolUseId, model); + if (pending.size > PENDING_TASK_MODEL_CAP) { + const oldest = pending.keys().next(); + if (!oldest.done) { + pending.delete(oldest.value); + } + } +} + interface ClaudeSessionContext { session: ProviderSession; readonly promptQueue: Queue.Queue; @@ -263,6 +293,12 @@ interface ClaudeSessionContext { readonly inFlightTools: Map; readonly claudeTasks: Map; readonly taskAgents: Map; + /** + * Authoritative subagent models from assistant snapshots that arrived before + * their task_started registered the task, keyed by parent_tool_use_id. + * Written through `rememberPendingTaskModel`, consumed by task_started. + */ + readonly pendingTaskModels: Map; /** * Last emitted workflow-member fingerprint per member slot. A coordinator * task_progress repeats the FULL member array every tick; without a @@ -283,9 +319,6 @@ interface ClaudeSessionContext { } interface ClaudeQueryRuntime extends AsyncIterable { - readonly interrupt: () => Promise; - /** SDK Query.stopTask — present on real queries; optional for test doubles. */ - readonly stopTask?: (taskId: string) => Promise; readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; @@ -516,6 +549,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) { @@ -547,6 +581,9 @@ function makeClaudeTokenUsageSnapshot(input: { ...(input.compactsAutomatically !== undefined ? { compactsAutomatically: input.compactsAutomatically } : {}), + ...(input.autoCompactThreshold !== undefined + ? { autoCompactThreshold: input.autoCompactThreshold } + : {}), }; } @@ -581,11 +618,13 @@ function normalizeClaudeContextUsageApiSnapshot( value: SDKControlGetContextUsageResponse, totalProcessedTokens?: number, ): ThreadTokenUsageSnapshot | undefined { + const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold); return makeClaudeTokenUsageSnapshot({ activeTokens: value.totalTokens, contextWindow: value.maxTokens, ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}), compactsAutomatically: value.isAutoCompactEnabled, + ...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}), }); } @@ -2113,13 +2152,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } catch { return undefined; } - }); - if (!usage) { + }).pipe(Effect.timeoutOption("1 second")); + if (Option.isNone(usage) || !usage.value) { return undefined; } - context.lastKnownContextWindow = usage.maxTokens; - return normalizeClaudeContextUsageApiSnapshot(usage, totalProcessedTokens); + context.lastKnownContextWindow = usage.value.maxTokens; + return normalizeClaudeContextUsageApiSnapshot(usage.value, totalProcessedTokens); }); const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* ( @@ -2885,8 +2924,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const owningTaskId = agentIdForParentToolUse(context.taskAgents, assistantParentToolUseId); const snapshotModel = trimmedString(message.message.model); const owningAgent = owningTaskId ? context.taskAgents.get(owningTaskId) : undefined; - if (owningAgent && snapshotModel) { - owningAgent.model = snapshotModel; + if (snapshotModel) { + if (owningAgent) { + owningAgent.model = snapshotModel; + } else { + // The snapshot beat its task_started (or its tool_use_id was never + // recorded): hold the model until the task registers. + rememberPendingTaskModel( + context.pendingTaskModels, + assistantParentToolUseId, + snapshotModel, + ); + } } context.lastAssistantUuid = message.uuid; yield* updateResumeCursor(context); @@ -3200,12 +3249,20 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const owningAgentId = launchingTool?.agentId; // Model/effort: the Agent tool's input carries explicit overrides; // absent ones inherit the session's selection (SDK behavior). - // Subagent assistant snapshots later refine model with the - // authoritative API id. AgentInput.effort may be a named level or an - // integer. + // Subagent assistant snapshots refine model with the authoritative API + // id: one that already arrived is buffered and outranks the seed here, + // later ones refine the record in place. AgentInput.effort may be a + // named level or an integer. const launchInput = launchingTool?.input; + const toolUseId = message.tool_use_id; + const bufferedModel = toolUseId ? context.pendingTaskModels.get(toolUseId) : undefined; + if (toolUseId) { + context.pendingTaskModels.delete(toolUseId); + } const model = - trimmedString(launchInput?.model) ?? trimmedString(context.session.model ?? undefined); + bufferedModel ?? + trimmedString(launchInput?.model) ?? + trimmedString(context.session.model ?? undefined); const rawLaunchEffort = launchInput?.effort; const effort = trimmedString(rawLaunchEffort) ?? @@ -3654,8 +3711,42 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) { if (context.stopped) return; + // Schedule process termination before any cleanup that can wait on the + // provider. The SDK closes stdin, then escalates from SIGTERM to SIGKILL. + yield* Effect.try({ + try: () => context.query.close(), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.session.threadId, + detail: "Failed to close Claude runtime query.", + cause, + }), + }); + context.stopped = true; + for (const taskId of Array.from(context.liveTaskIds)) { + if (!context.liveTaskIds.delete(taskId)) { + continue; + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + payload: { + taskId: RuntimeTaskId.make(taskId), + status: "stopped", + ...taskLinkageFor(context.taskAgents, taskId), + }, + providerRefs: nativeProviderRefs(context), + }); + } + for (const [requestId, pending] of context.pendingApprovals) { yield* Deferred.succeed(pending.decision, "cancel"); const stamp = yield* makeEventStamp(); @@ -3694,26 +3785,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* Fiber.interrupt(streamFiber); } - yield* Effect.try({ - try: () => context.query.close(), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: context.session.threadId, - detail: "Failed to close Claude runtime query.", - cause, - }), - }).pipe( - Effect.catch((error) => - emitRuntimeError(context, "Failed to close Claude runtime query.", { - errorTag: error._tag, - provider: error.provider, - threadId: error.threadId, - detail: error.detail, - }), - ), - ); - const updatedAt = yield* nowIso; context.session = { ...context.session, @@ -3722,7 +3793,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( updatedAt, }; - if (options?.emitExitEvent !== false) { + if (options?.emitExitEvent !== false && sessions.get(context.session.threadId) === context) { const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.exited", @@ -3738,7 +3809,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - sessions.delete(context.session.threadId); + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } }); const requireSession = ( @@ -3805,16 +3878,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); yield* stopSessionInternal(existingContext, { emitExitEvent: false, - }).pipe( - // Replacement cleanup is best-effort: never block the new session on - // either typed failures or unexpected defects from tearing down the old one. - Effect.catchCause((cause) => - Effect.logWarning("claude.session.replace.stop-failed", { - threadId: input.threadId, - cause, - }), - ), - ); + }); } const startedAt = yield* nowIso; @@ -3843,6 +3907,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const inFlightTools = new Map(); const claudeTasks = new Map(); const taskAgents = new Map(); + const pendingTaskModels = new Map(); const workflowMemberFingerprints = new Map(); const liveTaskIds = new Set(); @@ -3938,6 +4003,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); @@ -3986,6 +4057,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], @@ -4091,6 +4232,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); @@ -4146,6 +4292,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; @@ -4181,6 +4331,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 @@ -4212,6 +4365,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], env: sessionEnvironment, additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), @@ -4305,6 +4460,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( inFlightTools, claudeTasks, taskAgents, + pendingTaskModels, workflowMemberFingerprints, liveTaskIds, turnState: undefined, @@ -4505,62 +4661,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, _turnId) { const context = yield* requireSession(threadId); - // Stop-everything semantics: users reach for Stop precisely when a - // fleet ran away. interrupt() alone only ends the parent turn — - // background subagents/shells keep running and keep burning tokens. - // Stop every live task first (best-effort per task: one refusal must - // not strand the rest or block the turn interrupt), then interrupt. - if (context.query.stopTask && context.liveTaskIds.size > 0) { - const liveIds = Array.from(context.liveTaskIds); - // Bounded: a wedged child's stopTask promise may never settle - // (Effect.ignore handles rejection, not non-resolution), and the - // parent interrupt below MUST still run — Stop matters most during - // runaway fleets (review finding). Per-task timeout keeps one hung - // child from consuming the whole budget. - yield* Effect.forEach( - liveIds, - (taskId) => - Effect.gen(function* () { - const stopAcknowledged = yield* Effect.tryPromise({ - // Invoke through the query object: SDK methods rely on `this`. - try: () => context.query.stopTask!(taskId), - catch: () => undefined, - }).pipe( - Effect.timeoutOption("3 seconds"), - Effect.orElseSucceed(() => Option.none()), - ); - if (Option.isNone(stopAcknowledged) || !context.liveTaskIds.delete(taskId)) { - return; - } - - // stopTask only acknowledges the control request. Its separate - // task_notification can lose the race with interrupt(), so make - // the acknowledged stop authoritative for the durable UI state. - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "task.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState - ? { turnId: asCanonicalTurnId(context.turnState.turnId) } - : {}), - payload: { - taskId: RuntimeTaskId.make(taskId), - status: "stopped", - ...taskLinkageFor(context.taskAgents, taskId), - }, - providerRefs: nativeProviderRefs(context), - }); - }).pipe(Effect.ignore), - { concurrency: 8, discard: true }, - ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); - } - yield* Effect.tryPromise({ - try: () => context.query.interrupt(), - catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), - }); + // interrupt() can acknowledge while resumed background tasks keep the + // CLI alive. Stop is a hard session boundary for Claude, so close the + // query and let the SDK escalate to SIGKILL when graceful exit fails. + yield* stopSessionInternal(context); }, ); @@ -4654,25 +4758,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return context !== undefined && !context.stopped; }); - const stopAll: ClaudeAdapterShape["stopAll"] = () => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: true, - }), - { discard: true }, + const stopSessions = Effect.fn("stopSessions")(function* ( + contexts: ReadonlyArray, + emitExitEvent: boolean, + ) { + const results = yield* Effect.forEach(contexts, (context) => + stopSessionInternal(context, { emitExitEvent }).pipe(Effect.result), ); + for (const result of results) { + if (result._tag === "Failure") { + return yield* Effect.fail(result.failure); + } + } + }); + + const stopAll: ClaudeAdapterShape["stopAll"] = () => + stopSessions(Array.from(sessions.values()), true); + yield* Effect.addFinalizer(() => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: false, - }), - { discard: true }, - ).pipe( + stopSessions(Array.from(sessions.values()), false).pipe( Effect.catch((cause) => Effect.logError("Failed to emit Claude session shutdown event.", { cause }), ), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index cc2b4eeb9c15..51854288ea1f 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -869,7 +869,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( status: "error", auth: { status: "unknown" }, message: isCommandMissingCause(error) - ? "Claude Agent CLI (`claude`) is not installed or not on PATH." + ? "Claude Agent CLI (`claude`) was not found on PATH." : "Failed to execute Claude Agent CLI health check.", }, }); @@ -934,7 +934,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 ba83df15d24d..1e80543ba0b2 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -107,6 +107,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { }), ); + public readonly uploadFeedbackImpl = vi.fn((_reason?: string) => + Promise.resolve({ threadId: "provider-thread-1" }), + ); + public readonly respondToRequestImpl = vi.fn( (_requestId: ApprovalRequestId, _decision: ProviderApprovalDecision): Promise => Promise.resolve(undefined), @@ -149,6 +153,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { return Effect.promise(() => this.rollbackThreadImpl(numTurns)); } + uploadFeedback(reason?: string) { + return Effect.promise(() => this.uploadFeedbackImpl(reason)); + } + respondToRequest(requestId: ApprovalRequestId, decision: ProviderApprovalDecision) { return Effect.promise(() => this.respondToRequestImpl(requestId, decision)); } @@ -407,6 +415,42 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("uploads feedback for the active Codex thread", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("thread-feedback"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + + const result = yield* adapter.uploadFeedback({ + threadId, + reason: "The agent stopped early.", + }); + + NodeAssert.deepStrictEqual(result, { feedbackId: "provider-thread-1" }); + NodeAssert.deepStrictEqual(runtime.uploadFeedbackImpl.mock.calls, [ + ["The agent stopped early."], + ]); + }), + ); + + it.effect("rejects feedback for an unknown Codex thread", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const result = yield* adapter + .uploadFeedback({ threadId: asThreadId("thread-feedback-missing") }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); + }), + ); + it.effect("maps codex model options before sending a turn", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; @@ -636,6 +680,67 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("does not reactivate an idle child after a parent interaction", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe( + Effect.forkChild, + ); + + const childEvent = (id: string, method: string, payload: Record) => ({ + id: asEventId(id), + kind: "notification" as const, + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload, + }); + + yield* runtime.emit( + childEvent("evt-child-running", "collabAgent/turnStarted", { + agentThreadId: "child-1", + agentPath: "/root/audit", + }), + ); + yield* runtime.emit( + childEvent("evt-child-idle", "collabAgent/turnCompleted", { + agentThreadId: "child-1", + agentPath: "/root/audit", + turn: { status: "completed" }, + }), + ); + yield* runtime.emit( + childEvent("evt-child-interacted", "collabAgent/activity", { + agentThreadId: "child-1", + agentPath: "/root/audit", + activityKind: "interacted", + }), + ); + yield* runtime.emit( + childEvent("evt-other-child-running", "collabAgent/turnStarted", { + agentThreadId: "child-2", + agentPath: "/root/other", + }), + ); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepStrictEqual( + events.map((event) => + event.type === "task.updated" + ? { taskId: event.payload.taskId, status: event.payload.status } + : { type: event.type }, + ), + [ + { taskId: "child-1", status: "running" }, + { taskId: "child-1", status: "idle" }, + { taskId: "child-2", status: "running" }, + ], + ); + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); @@ -737,6 +842,66 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("preserves failed and declined outcomes on completed tool items", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const items = [ + { + type: "commandExecution", + id: "failed-command", + command: "vp test run", + commandActions: [], + cwd: "/tmp", + exitCode: 1, + status: "failed", + }, + { + type: "mcpToolCall", + id: "failed-mcp", + server: "simulator", + tool: "build", + arguments: {}, + error: { message: "Build failed" }, + status: "failed", + }, + { + type: "fileChange", + id: "declined-change", + changes: [], + status: "declined", + }, + ] as const; + + for (const item of items) { + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId(`evt-${item.id}`), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId(item.id), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item, + }, + }); + + const firstEvent = yield* Fiber.join(firstEventFiber); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + NodeAssert.equal(firstEvent.value.payload.status, item.status); + } + }), + ); + it.effect("maps completed plan items to canonical proposed-plan completion events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); @@ -1024,6 +1189,79 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps MCP elicitation requests into app access approvals", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-mcp-elicitation"), + kind: "request", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-08-24T00:00:00.000Z", + method: "mcpServer/elicitation/request", + requestKind: "mcp-elicitation", + requestId: ApprovalRequestId.make("req-safari"), + turnId: asTurnId("turn-1"), + payload: { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { type: "object", properties: {} }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "request.opened") { + return; + } + NodeAssert.equal(firstEvent.value.payload.requestType, "mcp_elicitation_approval"); + NodeAssert.equal(firstEvent.value.payload.appName, "Safari"); + NodeAssert.equal(firstEvent.value.payload.detail, "Allow ChatGPT to use Safari?"); + NodeAssert.deepStrictEqual(firstEvent.value.payload.options, [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Always allow this session" }, + { decision: "acceptAlways", label: "Always allow" }, + { decision: "accept", label: "Approve" }, + ]); + }), + ); + + it.effect("preserves MCP elicitation type when an app access request resolves", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-mcp-elicitation-resolved"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-08-24T00:00:00.000Z", + method: "item/requestApproval/decision", + requestKind: "mcp-elicitation", + requestId: ApprovalRequestId.make("req-safari"), + payload: { decision: "acceptAlways" }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "request.resolved") { + return; + } + NodeAssert.equal(firstEvent.value.payload.requestType, "mcp_elicitation_approval"); + NodeAssert.equal(firstEvent.value.payload.decision, "acceptAlways"); + }), + ); + it.effect("preserves file-read request type when mapping serverRequest/resolved", () => 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 480d9c4f9b7f..e04e68e174fd 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -59,6 +59,7 @@ import { ServerConfig } from "../../config.ts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, + describeMcpElicitation, makeCodexSessionRuntime, type CodexSessionRuntimeError, type CodexSessionRuntimeOptions, @@ -305,6 +306,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { return "file_read_approval"; case "item/fileChange/requestApproval": return "file_change_approval"; + case "mcpServer/elicitation/request": + return "mcp_elicitation_approval"; case "applyPatchApproval": return "apply_patch_approval"; case "execCommandApproval": @@ -328,6 +331,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical return "file_read_approval"; case "file-change": return "file_change_approval"; + case "mcp-elicitation": + return "mcp_elicitation_approval"; default: return "unknown"; } @@ -483,7 +488,9 @@ function mapItemLifecycle( lifecycle === "item.started" ? "inProgress" : lifecycle === "item.completed" - ? "completed" + ? "status" in item && (item.status === "failed" || item.status === "declined") + ? item.status + : "completed" : undefined; return { @@ -591,14 +598,9 @@ function mapCollabAgentEvent( }, ]; } - // interacted → the child is (again) actively driven. - return [ - { - ...base, - type: "task.updated", - payload: { taskId, status: "running", ...statusLinkage }, - }, - ]; + // Reading a child's result also emits "interacted" after its turn is idle. + // Only the child's turn or thread lifecycle can prove it resumed work. + return []; } case "collabAgent/turnStarted": return [ @@ -806,6 +808,11 @@ function mapToRuntimeEvents( ]; } + const elicitation = + event.method === "mcpServer/elicitation/request" + ? readPayload(EffectCodexSchema.McpServerElicitationRequestParams, event.payload) + : undefined; + const elicitationApproval = elicitation ? describeMcpElicitation(elicitation) : undefined; const detail = (() => { switch (event.method) { case "item/commandExecution/requestApproval": { @@ -822,6 +829,8 @@ function mapToRuntimeEvents( ); return payload?.reason ?? undefined; } + case "mcpServer/elicitation/request": + return elicitation?.message; case "applyPatchApproval": { const payload = readPayload( EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams, @@ -855,6 +864,12 @@ function mapToRuntimeEvents( payload: { requestType: toRequestTypeFromMethod(event.method), ...(detail ? { detail } : {}), + ...(elicitationApproval + ? { + appName: elicitationApproval.appName, + options: elicitationApproval.options, + } + : {}), ...(event.payload !== undefined ? { args: event.payload } : {}), }, }, @@ -1927,6 +1942,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); }; + const uploadFeedback: CodexAdapterShape["uploadFeedback"] = (input) => + requireSession(input.threadId).pipe( + Effect.flatMap((session) => session.runtime.uploadFeedback(input.reason)), + Effect.map(({ threadId }) => ({ feedbackId: threadId })), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionNotFoundError" + ? cause + : mapCodexRuntimeError(input.threadId, "feedback/upload", cause), + ), + ); + const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) => requireSession(threadId).pipe( Effect.flatMap((session) => session.runtime.respondToRequest(requestId, decision)), @@ -2015,6 +2041,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( compactSession, readThread, rollbackThread, + uploadFeedback, respondToRequest, respondToUserInput, stopSession, diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 471872d32d4c..afcd741ea91a 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -13,9 +13,11 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; +import { type ProviderApprovalDecision, type ProviderEvent, ThreadId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { assert, describe } from "vite-plus/test"; @@ -25,6 +27,14 @@ import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; const ROOT = wireFixture.rootThreadId; const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; const MEMORY = "memory-consolidation-thread"; +const decodeMcpElicitationResponse = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.Number, + result: Schema.Unknown, + }), + ), +); /** * The captured sequence, extended with the shapes the live capture didn't @@ -389,4 +399,116 @@ describe("CodexSessionRuntime collab integration", () => { yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + const elicitationCases = [ + { + decision: "accept", + response: { action: "accept", content: { approval: "once" } }, + }, + { + decision: "acceptForSession", + response: { + action: "accept", + _meta: { persist: "session" }, + content: { approval: "session" }, + }, + }, + { + decision: "acceptAlways", + response: { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }, + }, + { decision: "decline", response: { action: "decline" } }, + { decision: "cancel", response: { action: "cancel" } }, + ] satisfies ReadonlyArray<{ + readonly decision: ProviderApprovalDecision; + readonly response: Record; + }>; + + for (const { decision, response } of elicitationCases) { + it.live(`returns the MCP elicitation ${decision} response to Codex`, () => + Effect.gen(function* () { + const scriptedRequest = { + id: 7001, + method: "mcpServer/elicitation/request", + params: { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: ROOT, + turnId: wireFixture.responses.turnStart.turn.id, + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + enum: ["once", "session", "always"], + }, + }, + required: ["approval"], + }, + }, + }; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + completeTurnOnServerResponse: true, + notifications: [], + serverRequests: [scriptedRequest], + }; + const responsesPath = `${scriptPath}.responses`; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(responsesPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(responsesPath, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-mcp-elicitation"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "auto", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const approvalRequested = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.runForEach((event) => + event.method === "mcpServer/elicitation/request" + ? Deferred.succeed(approvalRequested, event).pipe(Effect.asVoid) + : event.method === "turn/completed" + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "Open Safari" }); + const approval = yield* Deferred.await(approvalRequested); + assert.equal(approval.requestKind, "mcp-elicitation"); + assert.isDefined(approval.requestId); + if (approval.requestId === undefined) return; + + yield* runtime.respondToRequest(approval.requestId, decision); + yield* Deferred.await(turnCompleted); + + const recordedResponse = yield* decodeMcpElicitationResponse( + NodeFS.readFileSync(responsesPath, "utf8"), + ); + assert.equal(recordedResponse.id, scriptedRequest.id); + assert.deepEqual(recordedResponse.result, response); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + } }); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 26e77f82a79e..7469818dcefd 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -6,16 +6,22 @@ import { mapCodexModelCapabilities, } from "./CodexProvider.ts"; -it("keeps only the GPT-5.6 Codex family out of legacy models", () => { +it("keeps current Codex models out of legacy models", () => { assert.deepStrictEqual( - ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.4"].map((model) => [ - model, - isLegacyCodexModel(model), - ]), + [ + "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], ], ); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 5c0f76dff4e3..93730046dc49 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,7 +62,13 @@ 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"]); +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); @@ -570,7 +576,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu auth: { status: "unknown" }, message: installed ? `Codex app-server provider probe failed: ${error.message}.` - : "Codex CLI (`codex`) is not installed or not on PATH.", + : "Codex CLI (`codex`) was not found on PATH.", }, }); } @@ -601,6 +607,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu checkedAt, models: snapshot.models, skills: snapshot.skills, + slashCommands: [ + { + name: "feedback", + description: "Send this thread and Codex logs to OpenAI", + input: { hint: "Describe the issue (optional)" }, + }, + ], probe: { installed: true, version: snapshot.version ?? null, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 007c1e773cef..612c08a64308 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -17,10 +17,12 @@ import { import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, + describeMcpElicitation, hasConfiguredMcpServer, isRecoverableThreadResumeError, makeMemoryConsolidationNotificationFilter, openCodexThread, + toMcpElicitationResponse, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); @@ -248,6 +250,208 @@ describe("buildTurnStartParams", () => { }); }); +describe("Codex MCP elicitation approvals", () => { + const request = { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + _meta: { + app_name: "Safari", + persist: ["session", "always"], + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + oneOf: [ + { const: "once", title: "Allow once" }, + { const: "session", title: "Allow for this session" }, + { const: "always", title: "Always allow Safari" }, + ], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + it("preserves the app name and advertised persistence choices", () => { + NodeAssert.deepStrictEqual(describeMcpElicitation(request), { + appName: "Safari", + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Allow for this session" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ], + }); + }); + + it("extracts the app name from a Computer Use request without metadata", () => { + const { _meta, ...requestWithoutMetadata } = request; + + NodeAssert.equal(describeMcpElicitation(requestWithoutMetadata).appName, "Safari"); + }); + + it("returns the accepted form option to Codex", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "accept"), { + action: "accept", + content: { approval: "once" }, + }); + }); + + it("returns session-scoped approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptForSession"), { + action: "accept", + _meta: { persist: "session" }, + content: { approval: "session" }, + }); + }); + + it("returns persistent approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("returns rejection without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "decline"), { + action: "decline", + }); + }); + + it("returns cancellation without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "cancel"), { + action: "cancel", + }); + }); + + it("supports boolean permanent-approval fields", () => { + const booleanRequest = { + ...request, + _meta: { app_name: "Safari" }, + requestedSchema: { + type: "object", + properties: { + always: { type: "boolean", title: "Always allow Safari" }, + }, + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.ok( + describeMcpElicitation(booleanRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(booleanRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { always: true }, + }); + }); + + it("preserves valid nullable MCP form fields and persistence choices", () => { + const nullableRequest = { + ...request, + _meta: { + app_name: null, + appName: "Safari", + connector_name: null, + persist: null, + target: null, + tool_params: null, + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + title: null, + description: null, + default: null, + enum: ["once", "always"], + enumNames: null, + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.equal(describeMcpElicitation(nullableRequest).appName, "Safari"); + NodeAssert.ok( + describeMcpElicitation(nullableRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(nullableRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("declines required form fields that an approval prompt cannot collect", () => { + const inputRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + email: { type: "string", format: "email" }, + }, + required: ["email"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(inputRequest, "accept"), { + action: "decline", + }); + }); + + it("does not approve URL elicitations without opening their requested URL", () => { + const urlRequest = { + mode: "url", + message: "Finish signing in to continue.", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + elicitationId: "sign-in-1", + url: "https://example.com/authorize", + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, "accept"), { + action: "decline", + }); + }); + + it("omits persistence choices that cannot satisfy required form fields", () => { + const onceOnlyRequest = { + ...request, + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + enum: ["once"], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(describeMcpElicitation(onceOnlyRequest).options, [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, + ]); + }); +}); + describe("buildCodexDeveloperInstructions", () => { it("appends runtime info after the mode instructions", () => { const instructions = buildCodexDeveloperInstructions("default", { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 316d391b5e54..f7d1b4ea79a7 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -6,6 +6,7 @@ import { ProviderItemId, type ProviderInstanceId, type ProviderApprovalDecision, + type ProviderApprovalOption, type ProviderEvent, type ProviderInteractionMode, type ProviderRequestKind, @@ -74,6 +75,58 @@ const CodexUserInputAnswerObject = Schema.Struct({ const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); const isCodexUserInputAnswerObject = Schema.is(CodexUserInputAnswerObject); const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); +const NullableMcpElicitationString = Schema.NullOr(Schema.String); +const McpElicitationMetadata = Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + appName: Schema.optionalKey(NullableMcpElicitationString), + connector_name: Schema.optionalKey(NullableMcpElicitationString), + connectorName: Schema.optionalKey(NullableMcpElicitationString), + allowPersistentApproval: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + persist: Schema.optionalKey( + Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + ), + target: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + tool_params: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), +}); +const McpElicitationFormField = Schema.Struct({ + type: Schema.optionalKey(NullableMcpElicitationString), + title: Schema.optionalKey(NullableMcpElicitationString), + description: Schema.optionalKey(NullableMcpElicitationString), + default: Schema.optionalKey(Schema.Unknown), + enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + oneOf: Schema.optionalKey( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + const: Schema.String, + title: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + ), +}); +const McpElicitationForm = Schema.Struct({ + properties: Schema.optionalKey(Schema.Record(Schema.String, McpElicitationFormField)), + required: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), +}); +const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); +const isMcpElicitationForm = Schema.is(McpElicitationForm); // TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated // `V2TurnStartParams` schema includes `collaborationMode` directly. @@ -144,6 +197,9 @@ export interface CodexSessionRuntimeShape { readonly rollbackThread: ( numTurns: number, ) => Effect.Effect; + readonly uploadFeedback: ( + reason?: string, + ) => Effect.Effect; readonly respondToRequest: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -230,6 +286,172 @@ interface PendingUserInput { readonly answers: Deferred.Deferred; } +type McpElicitationPersistenceDecision = Extract< + ProviderApprovalDecision, + "acceptForSession" | "acceptAlways" +>; + +function mcpElicitationPersistenceDecision( + value: string, +): McpElicitationPersistenceDecision | null { + const normalized = value.toLowerCase(); + if (normalized.includes("session")) return "acceptForSession"; + if ( + normalized.includes("always") || + normalized.includes("permanent") || + normalized.includes("forever") || + normalized.includes("persistent") + ) { + return "acceptAlways"; + } + return null; +} + +function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) { + if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) { + return undefined; + } + return payload.requestedSchema; +} + +function mcpElicitationFieldOptions(field: typeof McpElicitationFormField.Type) { + if (field.oneOf) { + return field.oneOf.map((option) => ({ value: option.const, label: option.title })); + } + return (field.enum ?? []).map((value, index) => ({ + value, + label: field.enumNames?.[index], + })); +} + +function isMcpElicitationPersistenceField( + key: string, + field: typeof McpElicitationFormField.Type, +): boolean { + return ( + mcpElicitationPersistenceDecision(key) !== null || + key.toLowerCase() === "persist" || + mcpElicitationPersistenceDecision(field.title ?? "") !== null || + mcpElicitationPersistenceDecision(field.description ?? "") !== null + ); +} + +/** Returns the app and approval choices advertised by an MCP elicitation. */ +export function describeMcpElicitation( + payload: EffectCodexSchema.McpServerElicitationRequestParams, +): { readonly appName: string; readonly options: ReadonlyArray } { + const metadata = isMcpElicitationMetadata(payload._meta) ? payload._meta : undefined; + const appName = + metadata?.app_name ?? + metadata?.appName ?? + metadata?.app ?? + metadata?.target?.app ?? + metadata?.target?.name ?? + metadata?.tool_params?.app_name ?? + metadata?.tool_params?.app ?? + payload.message.match(/^Allow ChatGPT to use (.+?)\?$/i)?.[1] ?? + metadata?.connector_name ?? + metadata?.connectorName ?? + payload.serverName; + const persistenceOptions = new Map(); + const persist = metadata?.persist; + for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) { + const decision = mcpElicitationPersistenceDecision(value); + if (decision) persistenceOptions.set(decision, ""); + } + if (metadata?.allowPersistentApproval) { + persistenceOptions.set("acceptAlways", ""); + } + + const form = mcpElicitationFormFields(payload); + for (const [key, field] of Object.entries(form?.properties ?? {})) { + for (const option of mcpElicitationFieldOptions(field)) { + const decision = mcpElicitationPersistenceDecision(option.value); + if (decision) persistenceOptions.set(decision, option.label ?? ""); + } + if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + persistenceOptions.set("acceptAlways", field.title ?? ""); + } + } + + return { + appName, + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + ...(persistenceOptions.has("acceptForSession") && + toMcpElicitationResponse(payload, "acceptForSession").action === "accept" + ? [ + { + decision: "acceptForSession" as const, + label: persistenceOptions.get("acceptForSession") || "Always allow this session", + }, + ] + : []), + ...(persistenceOptions.has("acceptAlways") && + toMcpElicitationResponse(payload, "acceptAlways").action === "accept" + ? [ + { + decision: "acceptAlways" as const, + label: persistenceOptions.get("acceptAlways") || "Always allow", + }, + ] + : []), + { decision: "accept", label: "Approve" }, + ], + }; +} + +/** Converts a T3 approval decision into the MCP elicitation wire response. */ +export function toMcpElicitationResponse( + payload: EffectCodexSchema.McpServerElicitationRequestParams, + decision: ProviderApprovalDecision, +): EffectCodexSchema.McpServerElicitationRequestResponse { + if (decision === "decline" || decision === "cancel") { + return { action: decision }; + } + + if (payload.mode === "url") { + return { action: "decline" }; + } + + const persist = + decision === "acceptForSession" + ? "session" + : decision === "acceptAlways" + ? "always" + : undefined; + const form = mcpElicitationFormFields(payload); + const content: Record = {}; + + for (const [key, field] of Object.entries(form?.properties ?? {})) { + const options = mcpElicitationFieldOptions(field); + const chosenOption = options.find((option) => + persist + ? mcpElicitationPersistenceDecision(option.value) === decision + : /once|accept|approve|allow/i.test(option.value) && + mcpElicitationPersistenceDecision(option.value) === null, + ); + if (chosenOption) { + content[key] = chosenOption.value; + } else if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + content[key] = decision === "acceptAlways"; + } else if (field.default !== undefined && field.default !== null) { + content[key] = field.default; + } + } + + if (form?.required?.some((key) => !Object.hasOwn(content, key))) { + return { action: "decline" }; + } + + return { + action: "accept", + ...(persist ? { _meta: { persist } } : {}), + ...(form ? { content } : {}), + }; +} + type CodexServerNotification = { readonly [M in CodexRpc.ServerNotificationMethod]: { readonly method: M; @@ -1551,7 +1773,7 @@ export const makeCodexSessionRuntime = ( ), ); return { - decision: resolved, + decision: resolved === "acceptAlways" ? "acceptForSession" : resolved, } satisfies EffectCodexSchema.CommandExecutionRequestApprovalResponse; }), ); @@ -1609,11 +1831,76 @@ export const makeCodexSessionRuntime = ( ), ); return { - decision: resolved, + decision: resolved === "acceptAlways" ? "acceptForSession" : resolved, } satisfies EffectCodexSchema.FileChangeRequestApprovalResponse; }), ); + yield* client.handleServerRequest("mcpServer/elicitation/request", (payload) => + Effect.gen(function* () { + if (toMcpElicitationResponse(payload, "accept").action !== "accept") { + yield* Effect.logWarning("Declined an unsupported MCP elicitation.", { + serverName: payload.serverName, + mode: payload.mode, + }); + return { + action: "decline", + } satisfies EffectCodexSchema.McpServerElicitationRequestResponse; + } + + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("mcp-elicitation-request")); + const turnId = payload.turnId + ? TurnId.make(payload.turnId) + : (yield* Ref.get(sessionRef)).activeTurnId; + const jsonRpcId = payload.mode === "url" ? payload.elicitationId : requestId; + const decision = yield* Deferred.make(); + + yield* Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.set(requestId, { + requestId, + jsonRpcId, + requestKind: "mcp-elicitation", + turnId, + itemId: undefined, + decision, + }); + return next; + }); + yield* Ref.update(approvalCorrelationsRef, (current) => { + const next = new Map(current); + next.set(jsonRpcId, { + requestId, + requestKind: "mcp-elicitation", + turnId, + itemId: undefined, + }); + return next; + }); + + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "mcpServer/elicitation/request", + requestId, + requestKind: "mcp-elicitation", + ...(turnId ? { turnId } : {}), + payload, + }); + + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + return toMcpElicitationResponse(payload, resolved); + }), + ); + yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => Effect.gen(function* () { const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); @@ -1976,6 +2263,16 @@ export const makeCodexSessionRuntime = ( }); return parseThreadSnapshot(response); }), + uploadFeedback: (reason) => + Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + return yield* client.request("feedback/upload", { + classification: "bug", + includeLogs: true, + ...(reason ? { reason } : {}), + threadId: providerThreadId, + }); + }), respondToRequest: (requestId, decision) => Effect.gen(function* () { const pending = (yield* Ref.get(pendingApprovalsRef)).get(requestId); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index b3c1a56af062..60b10cf25487 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -265,7 +265,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { assert.equal(delta.payload.delta, "hello from mock"); // The middle segment is a per-run id: it keeps a resumed session from // reusing the item ids of its earlier runs. - assert.match(String(delta.itemId), /^assistant:mock-session-1:[^:]+:segment:0$/); + assert.match(String(delta.itemId), /^assistant:mock-session-1:runtime:[^:]+:segment:0$/); } const assistantCompleted = runtimeEvents.find( @@ -738,7 +738,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { assert.equal(contentDelta.payload.delta, "hello from mock"); // The middle segment is a per-run id: it keeps a resumed session // from reusing the item ids of its earlier runs. - assert.match(String(contentDelta.itemId), /^assistant:mock-session-1:[^:]+:segment:0$/); + assert.match( + String(contentDelta.itemId), + /^assistant:mock-session-1:runtime:[^:]+:segment:0$/, + ); } }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 93f4b97995dc..7c07fe5ad4b8 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -239,19 +239,16 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { name: "openclaw-review", description: "Review OpenClaw workflow changes.", location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", - content: "---\nname: openclaw-review\n---\n", }, { name: "openclaw-triage", description: "Triage OpenClaw routing issues.", location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", - content: "---\nname: openclaw-triage\n---\n", }, { name: "missing-location", description: "This incomplete SDK row should be skipped.", location: "", - content: "---\nname: missing-location\n---\n", }, ], }; diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index c4145ecf1a0e..280601275a7e 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -40,6 +40,7 @@ const fakeCodexAdapter: CodexAdapter.CodexAdapterShape = { hasSession: vi.fn(), readThread: vi.fn(), rollbackThread: vi.fn(), + uploadFeedback: vi.fn(), stopAll: vi.fn(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 850193165d56..7fdbf512c0a1 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -107,6 +107,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => homePath: "", customModels: [], launchArgs: "", + autoCompactWindow: "", ...overrides, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 85a70828744e..90704ba9b690 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -391,6 +391,13 @@ it.layer( shortDescription: "Debug failing GitHub Actions checks", }, ]); + assert.deepStrictEqual(status.slashCommands, [ + { + name: "feedback", + description: "Send this thread and Codex logs to OpenAI", + input: { hint: "Describe the issue (optional)" }, + }, + ]); }), ); @@ -502,7 +509,7 @@ it.layer( assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); + assert.strictEqual(status.message, "Codex CLI (`codex`) was not found on PATH."); }), ); @@ -1525,10 +1532,7 @@ it.layer( "Real Codex probe against a missing binary should surface as 'error' in the aggregator", ); assert.strictEqual(codexPersonal?.installed, false); - assert.strictEqual( - codexPersonal?.message, - "Codex CLI (`codex`) is not installed or not on PATH.", - ); + assert.strictEqual(codexPersonal?.message, "Codex CLI (`codex`) was not found on PATH."); }).pipe(Effect.provide(runtimeServices)); }), ); @@ -1709,6 +1713,90 @@ it.layer( }), ); + it.effect( + "keeps Cursor disabled and skips provider probing when settings use their defaults", + () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + codex: { + enabled: false, + }, + grok: { + enabled: false, + }, + }, + }), + ), + ); + let cursorSpawned = false; + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-cursor-defaults-", + }), + ), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + if (command === "cursor-agent") { + cursorSpawned = true; + } + const joined = args.join(" "); + if (joined === "--version") { + return { + stdout: `${command} 1.0.0\n`, + stderr: "", + code: 0, + }; + } + if (joined === "auth status") { + return { + stdout: '{"authenticated":true}\n', + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected args: ${command} ${joined}`); + }), + ), + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + providerRegistryLayer, + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const providers = yield* registry.getProviders; + const cursorProvider = providers.find( + (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), + ); + + assert.strictEqual(cursorProvider?.enabled, false); + assert.strictEqual(cursorSpawned, false); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("keeps cursor disabled and skips probing when the provider setting is disabled", () => Effect.gen(function* () { const serverSettings = yield* makeMutableServerSettingsService( @@ -2200,6 +2288,10 @@ it.layer( ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "review", description: "Review a pull request", @@ -2243,6 +2335,10 @@ it.layer( ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "ui", description: "Explore and refine UI", @@ -2302,10 +2398,7 @@ it.layer( assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Claude Agent CLI (`claude`) is not installed or not on PATH.", - ); + assert.strictEqual(status.message, "Claude Agent CLI (`claude`) was not found on PATH."); }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 6885cb5376b1..c2129aef7bc4 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -9,6 +9,8 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderTurnStartResult, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -198,6 +200,13 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { Effect.succeed({ threadId, turns: [] }), ); + const uploadFeedback = vi.fn( + ( + input: ProviderUploadFeedbackInput, + ): Effect.Effect => + Effect.succeed({ feedbackId: `feedback-${input.threadId}` }), + ); + const stopAll = vi.fn( (): Effect.Effect => Effect.sync(() => { @@ -220,6 +229,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { hasSession, readThread, rollbackThread, + ...(provider === CODEX_DRIVER ? { uploadFeedback } : {}), stopAll, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -255,6 +265,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { hasSession, readThread, rollbackThread, + uploadFeedback, stopAll, }; } @@ -835,6 +846,68 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance const routing = makeProviderServiceLayer(); +it.effect( + "ProviderServiceLive uploads feedback through the adapter that recovered the session", + () => + Effect.gen(function* () { + const original = makeFakeCodexAdapter(); + const replacement = makeFakeCodexAdapter(); + const baseRegistry = makeAdapterRegistryMock({ [CODEX_DRIVER]: original.adapter }); + let swapAfterFirstLookup = false; + let feedbackLookupCount = 0; + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { + ...baseRegistry, + getByInstance: (instanceId) => { + if (instanceId !== codexInstanceId) { + return baseRegistry.getByInstance(instanceId); + } + const useReplacement = swapAfterFirstLookup && feedbackLookupCount++ > 0; + return Effect.succeed(useReplacement ? replacement.adapter : original.adapter); + }, + }; + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-adapter-replacement"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* original.stopSession(threadId); + original.uploadFeedback.mockClear(); + replacement.uploadFeedback.mockClear(); + swapAfterFirstLookup = true; + + const result = yield* provider.uploadFeedback({ threadId }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.strictEqual(original.uploadFeedback.mock.calls.length, 0); + assert.deepStrictEqual(replacement.uploadFeedback.mock.calls, [[{ threadId }]]); + }).pipe(Effect.provide(providerLayer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () => Effect.gen(function* () { const codex = makeFakeCodexAdapter(); @@ -1181,6 +1254,93 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes feedback to the Codex adapter and returns its feedback ID", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-route"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + routing.codex.uploadFeedback.mockClear(); + + const result = yield* provider.uploadFeedback({ + threadId, + reason: "The agent stopped early.", + }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.deepStrictEqual(routing.codex.uploadFeedback.mock.calls, [ + [{ threadId, reason: "The agent stopped early." }], + ]); + }), + ); + + it.effect("recovers a stopped Codex session before uploading feedback", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-recover"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/feedback-project", + runtimeMode: "full-access", + }); + yield* routing.codex.stopSession(threadId); + routing.codex.startSession.mockClear(); + routing.codex.uploadFeedback.mockClear(); + + const result = yield* provider.uploadFeedback({ threadId }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.strictEqual(routing.codex.startSession.mock.calls.length, 1); + assert.deepStrictEqual(routing.codex.uploadFeedback.mock.calls, [[{ threadId }]]); + }), + ); + + it.effect("rejects feedback for providers that do not support uploads", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-claude"); + yield* provider.startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }); + + const error = yield* provider.uploadFeedback({ threadId }).pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.include(error.issue, "does not support feedback uploads"); + routing.claude.startSession.mockClear(); + }), + ); + + it.effect("does not restart an unsupported provider before rejecting feedback", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-unsupported-stopped"); + yield* provider.startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* routing.claude.stopSession(threadId); + routing.claude.startSession.mockClear(); + + const error = yield* provider.uploadFeedback({ threadId }).pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.include(error.issue, "does not support feedback uploads"); + assert.strictEqual(routing.claude.startSession.mock.calls.length, 0); + }), + ); + it.effect("appends attachment file paths to the turn input text", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 3bf7e12fbef9..7d1ddd067f1f 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -20,6 +20,7 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, + ProviderUploadFeedbackInput, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, @@ -1329,6 +1330,47 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); + const uploadFeedback: ProviderServiceMethod<"uploadFeedback"> = Effect.fn("uploadFeedback")( + function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.uploadFeedback", + schema: ProviderUploadFeedbackInput, + payload: rawInput, + }); + let routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.uploadFeedback", + allowRecovery: false, + }); + if (routed.adapter.uploadFeedback === undefined) { + return yield* toValidationError( + "ProviderService.uploadFeedback", + `Provider '${routed.adapter.provider}' does not support feedback uploads.`, + ); + } + if (!routed.isActive) { + routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.uploadFeedback", + allowRecovery: true, + }); + } + const uploadFeedback = routed.adapter.uploadFeedback; + if (uploadFeedback === undefined) { + return yield* toValidationError( + "ProviderService.uploadFeedback", + `Provider '${routed.adapter.provider}' does not support feedback uploads.`, + ); + } + yield* Effect.annotateCurrentSpan({ + "provider.operation": "upload-feedback", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + }); + return yield* uploadFeedback(input); + }, + ); + const runStopAll = Effect.fn("runStopAll")(function* () { const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; @@ -1572,6 +1614,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( getCapabilities, getInstanceInfo, rollbackConversation, + uploadFeedback, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index da7dd1994399..bc3a1fc08909 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -189,6 +189,7 @@ describe("ProviderSessionReaper", () => { }); }, rollbackConversation: () => unsupported(), + uploadFeedback: () => unsupported(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Services/CodexAdapter.ts b/apps/server/src/provider/Services/CodexAdapter.ts index 33fe0fa12be0..a0d9c0c28e9e 100644 --- a/apps/server/src/provider/Services/CodexAdapter.ts +++ b/apps/server/src/provider/Services/CodexAdapter.ts @@ -16,4 +16,8 @@ import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; * CodexAdapterShape — per-instance Codex adapter contract. Carries * a branded driver kind as the nominal discriminant. */ -export interface CodexAdapterShape extends ProviderAdapterShape {} +export interface CodexAdapterShape extends ProviderAdapterShape { + readonly uploadFeedback: NonNullable< + ProviderAdapterShape["uploadFeedback"] + >; +} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 2643a518a533..1fd536c12c3b 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -16,6 +16,8 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, ThreadId, ProviderTurnStartResult, TurnId, @@ -120,6 +122,13 @@ export interface ProviderAdapterShape { numTurns: number, ) => Effect.Effect; + /** + * Upload a thread to the provider when the adapter supports feedback. + */ + readonly uploadFeedback?: ( + input: ProviderUploadFeedbackInput, + ) => Effect.Effect; + /** * Stop all sessions owned by this adapter. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 99929297c30c..29917f01e740 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -22,6 +22,8 @@ import type { ProviderSession, ProviderSessionStartInput, ProviderStopSessionInput, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, ThreadId, ProviderTurnStartResult, } from "@t3tools/contracts"; @@ -113,6 +115,13 @@ export interface ProviderServiceShape { readonly numTurns: number; }) => Effect.Effect; + /** + * Upload a thread and return the provider's shareable feedback identifier. + */ + readonly uploadFeedback: ( + input: ProviderUploadFeedbackInput, + ) => Effect.Effect; + /** * Canonical provider runtime event stream. * diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 94d21c4f395e..cab2f07725d2 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,7 @@ import { parseSessionUpdateEvent, sessionUpdateIsReplay, syntheticLoadSessionResponseFromInitialize, + type AcpToolCallState, } from "./AcpRuntimeModel.ts"; describe("AcpRuntimeModel", () => { @@ -453,4 +455,281 @@ 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", + }); + }); + + describe("decideToolCallUpdateEmission", () => { + const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) => + ({ + toolCallId: "tool-1", + title: "Grok Tool", + ...(status ? { status } : {}), + ...(detail ? { detail } : {}), + data: {}, + }) satisfies AcpToolCallState; + + 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", () => { + expect( + decideToolCallUpdateEmission({ + previous: toolCall("frame 1", "inProgress"), + next: toolCall("frame 1", "inProgress"), + lastEmittedDetailLength: 7, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: false, 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 7605837e33e5..e2d70f7eb62f 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -272,25 +272,97 @@ 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; +} + 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 nestedContent = entry.content; - if (nestedContent.type !== "text") { - continue; - } - const text = nestedContent.text.trim(); - if (text.length > 0) { + const text = toolCallContentText(entry)?.trim(); + if (text) { chunks.push(text); } } - return chunks.length > 0 ? chunks.join("\n") : undefined; + if (chunks.length === 0) { + return { text: undefined, content }; + } + const joined = chunks.join("\n"); + if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return { text: joined, content }; + } + const bounded = boundToolCallOutputText(joined); + // Collapse the text entries into a single bounded one at the final contributing text entry, + // and leave every other entry kind (diffs, images, resource links) in its original relative + // order. The retained tail came from that text entry, so placing it there also preserves its + // ordering relative to interleaved non-text content and ignores later blank text entries. + const lastContributingTextIndex = content.reduce( + (lastIndex, entry, index) => (toolCallContentText(entry)?.trim() ? index : lastIndex), + -1, + ); + const boundedContent = content.flatMap((entry, index) => { + if (toolCallContentText(entry) === undefined) { + return [entry]; + } + if (index !== lastContributingTextIndex) { + return []; + } + return [{ type: "content", content: { type: "text", text: bounded } } as const]; + }); + return { text: bounded, content: boundedContent }; } function normalizeToolKind(kind: unknown): string | undefined { @@ -334,7 +406,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 @@ -351,10 +424,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; @@ -432,6 +505,53 @@ 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; +} + +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 (!next.detail) { + return { emit: false, skippedSinceEmit }; + } + if (previous === undefined || previous.title !== next.title) { + return { emit: true, skippedSinceEmit: 0 }; + } + if (previous.detail === next.detail) { + return { emit: false, skippedSinceEmit }; + } + const grewMeaningfully = + lastEmittedDetailLength === undefined || + Math.abs(next.detail.length - 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 { @@ -513,6 +633,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; @@ -556,7 +703,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat events.push({ _tag: "ToolCallUpdated", toolCall, - rawPayload: params, + rawPayload: boundToolCallRawPayload(params, upd, toolCall), }); } break; @@ -567,7 +714,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 60e58ac4f899..a3668ad5f2f3 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -22,6 +22,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectSessionConfigOptionValues, + decideToolCallUpdateEmission, extractModelConfigId, findSessionConfigOption, mergeToolCallState, @@ -35,6 +36,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); } @@ -317,7 +324,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()); // Scopes assistant item ids to this run, so resuming a session cannot mint // ids that already belong to messages from an earlier run of it. Wall clock // separates runs across process restarts (where a bare counter would reset @@ -977,7 +984,7 @@ const handleSessionUpdate = ({ }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; - readonly toolCallsRef: Ref.Ref>; + readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; readonly params: EffectAcpSchema.SessionNotification; }): Effect.Effect => @@ -992,18 +999,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 + ? nextToolCall.detail?.length + : 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, { @@ -1088,21 +1108,8 @@ function seedAvailableModes(currentModeId: string): ReadonlyArray<{ return [...defaults, { id: currentModeId, name: currentModeId }]; } -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; -} - export const assistantItemId = (sessionId: string, runId: string, segmentIndex: number) => - `assistant:${sessionId}:${runId}:segment:${segmentIndex}`; + `assistant:${sessionId}:runtime:${runId}:segment:${segmentIndex}`; const ensureActiveAssistantSegment = ({ queue, diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 8d5ba353389d..f02bf997c5d1 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -258,7 +258,7 @@ describe("parseAgentListCliOutput", () => { }); describe("parseSkillsCliOutput", () => { - it("parses skill metadata from the CLI JSON output", () => { + it("parses only skill metadata from the CLI JSON output", () => { const result = parseSkillsCliOutput( JSON.stringify([ { @@ -275,7 +275,6 @@ describe("parseSkillsCliOutput", () => { name: "review-pr", description: "Review a pull request.", location: "/tmp/review-pr/SKILL.md", - content: "---\nname: review-pr\n---\n", }, ]); }); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 8b22a52a2060..7db63745eafe 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -4,13 +4,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type { OpencodeClient } from "@opencode-ai/sdk/v2"; import { 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 { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); -it.layer(testLayer)("loadOpenCodeInventory", (it) => { +it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { it.effect("keeps provider inventory when skill discovery fails", () => Effect.gen(function* () { const runtime = yield* OpenCodeRuntime; @@ -38,4 +45,121 @@ it.layer(testLayer)("loadOpenCodeInventory", (it) => { NodeAssert.deepEqual(inventory.skills, []); }), ); + + it.effect("keeps only SDK skill metadata in inventory", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const client = { + provider: { + list: () => + Promise.resolve({ + data: { + connected: ["openai"], + all: [], + default: {}, + }, + }), + }, + app: { + agents: () => Promise.resolve({ data: [] }), + skills: () => + Promise.resolve({ + data: [ + { + name: "review", + description: "Review code changes", + location: "/skills/review/SKILL.md", + content: "unused skill content", + }, + ], + }), + }, + } as unknown as OpencodeClient; + + const inventory = yield* runtime.loadOpenCodeInventory(client); + + NodeAssert.deepEqual(inventory.skills, [ + { + name: "review", + description: "Review code changes", + location: "/skills/review/SKILL.md", + }, + ]); + }), + ); + + it.effect("drops oversized CLI skill output without losing the model inventory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostEnvironment = yield* HostProcessEnvironment; + const executablePath = yield* HostProcessExecutablePath; + const hostPlatform = yield* HostProcessPlatform; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-inventory-" }); + const isWindows = hostPlatform === "win32"; + const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); + const scriptPath = path.join(tempDir, "opencode.mjs"); + const oversizedContentBytes = 8 * 1024 * 1024 + 1; + + yield* fs.writeFileString( + scriptPath, + [ + 'if (process.argv[2] === "models") {', + ' process.stdout.write(`openai/gpt-test\\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\\n`);', + '} else if (process.argv[2] === "debug") {', + ` const content = "x".repeat(${oversizedContentBytes});`, + ' process.stdout.write(`[{"name":"oversized","content":"${content}"}]`);', + "}", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + binaryPath, + [ + ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), + isWindows + ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' + : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', + "", + ].join("\n"), + ); + if (!isWindows) { + yield* fs.chmod(binaryPath, 0o755); + } + + const runtime = yield* OpenCodeRuntime; + const inventory = yield* runtime.loadInventoryFromCli({ + binaryPath, + cwd: tempDir, + environment: { + ...hostEnvironment, + T3_TEST_NODE_BINARY: executablePath, + T3_TEST_OPENCODE_SCRIPT: scriptPath, + }, + }); + + NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + NodeAssert.equal(inventory.skills.length, 0); + }), + ); + + it.effect("caps and drains command stdout and stderr when requested", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const executablePath = yield* HostProcessExecutablePath; + const outputBytes = 2 * 1024 * 1024; + const result = yield* runtime.runOpenCodeCommand({ + binaryPath: executablePath, + args: [ + "-e", + `process.stdout.write("o".repeat(${outputBytes})); process.stderr.write("e".repeat(${outputBytes}));`, + ], + maxOutputBytes: 64, + }); + + NodeAssert.equal(result.stdout, "o".repeat(64)); + NodeAssert.equal(result.stderr, "e".repeat(64)); + NodeAssert.equal(result.code, 0); + }), + ); }); diff --git a/apps/server/src/provider/opencodeRuntime.permissions.test.ts b/apps/server/src/provider/opencodeRuntime.permissions.test.ts new file mode 100644 index 000000000000..ad95e38d1495 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.permissions.test.ts @@ -0,0 +1,44 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { buildOpenCodePermissionRules } from "./opencodeRuntime.ts"; + +function actionFor( + runtimeMode: Parameters[0], + permission: string, +) { + return buildOpenCodePermissionRules(runtimeMode).find((rule) => rule.permission === permission) + ?.action; +} + +describe("buildOpenCodePermissionRules", () => { + it("pre-approves edits once the user has chosen to auto-accept them", () => { + NodeAssert.equal(actionFor("auto-accept-edits", "edit"), "allow"); + }); + + it("still asks before editing when approval is required", () => { + NodeAssert.equal(actionFor("approval-required", "edit"), "ask"); + }); + + // Documented in docs/user/permission-modes.md: providers without an AI + // reviewer, OpenCode among them, fall back to Supervised for "auto". + it("leaves auto asking, as the docs say it does without a reviewer", () => { + NodeAssert.equal(actionFor("auto", "edit"), "ask"); + }); + + it("keeps asking for everything else in the auto modes", () => { + for (const runtimeMode of ["auto-accept-edits", "auto"] as const) { + NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "*"), "ask"); + } + }); + + it("allows everything only under full access", () => { + NodeAssert.deepEqual(buildOpenCodePermissionRules("full-access"), [ + { permission: "*", pattern: "*", action: "allow" }, + ]); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index b6da61a2c2bb..02d7a72aa3a4 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -51,6 +51,7 @@ export function resolveOpenCodeConfigContent( 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 exitCode: Effect.Effect; @@ -124,14 +125,12 @@ export interface OpenCodeSkill { readonly name?: string | null; readonly description?: string | null; readonly location?: string | null; - readonly content?: string | null; } const OpenCodeSkillSchema = Schema.Struct({ name: Schema.optionalKey(Schema.NullOr(Schema.String)), description: Schema.optionalKey(Schema.NullOr(Schema.String)), location: Schema.optionalKey(Schema.NullOr(Schema.String)), - content: Schema.optionalKey(Schema.NullOr(Schema.String)), }); const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), @@ -171,6 +170,7 @@ export interface OpenCodeRuntimeShape { readonly args: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; readonly cwd?: string; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly createOpenCodeSdkClient: (input: { readonly baseUrl: string; @@ -375,10 +375,16 @@ export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): Permissi return [{ permission: "*", pattern: "*", action: "allow" }]; } + // "Auto-accept edits" is documented as "auto-approve edits, ask before other + // actions", so prompting for every edit ignores the mode the user picked. + // "auto" is left asking on purpose: the docs say providers without an AI + // reviewer, OpenCode among them, fall back to Supervised for that mode. + const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; + return [ { permission: "*", pattern: "*", action: "ask" }, { permission: "bash", pattern: "*", action: "ask" }, - { permission: "edit", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: editAction }, { permission: "webfetch", pattern: "*", action: "ask" }, { permission: "websearch", pattern: "*", action: "ask" }, { permission: "codesearch", pattern: "*", action: "ask" }, @@ -449,8 +455,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), ); + const collectOptions = + input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; const [stdout, stderr, code] = yield* Effect.all( - [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode], + [ + collectStreamAsString(child.stdout, collectOptions), + collectStreamAsString(child.stderr, collectOptions), + child.exitCode, + ], { concurrency: "unbounded" }, ); const exitCode = Number(code); @@ -706,7 +718,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const loadSkills = (client: OpencodeClient) => runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( - Effect.map((result) => (result.data ?? []) as ReadonlyArray), + Effect.map((result) => + (result.data ?? []).map((skill) => ({ + name: skill.name, + ...(skill.description === undefined ? {} : { description: skill.description }), + location: skill.location, + })), + ), Effect.orElseSucceed((): ReadonlyArray => []), ); @@ -736,6 +754,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { runOpenCodeCommand({ binaryPath: input.binaryPath, args: ["debug", "skill"], + maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, ...commandContext, }).pipe(Effect.exit); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 03b4cf4a3b6e..adbe110d9408 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -255,5 +255,6 @@ export function buildServerProvider(input: { export const collectStreamAsString = ( stream: Stream.Stream, + options?: { readonly maxBytes?: number | undefined }, ): Effect.Effect => - collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text)); + collectUint8StreamText({ stream, ...options }).pipe(Effect.map((collected) => collected.text)); diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index e9badc5a2b30..e6cd05500f21 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -17,6 +17,7 @@ const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); let turnStartCount = 0; +let activeTurn; const rl = NodeReadline.createInterface({ input: process.stdin }); rl.on("line", (line) => { @@ -27,6 +28,23 @@ rl.on("line", (line) => { return; } const { id, method } = message; + if (method === undefined && script.serverRequests?.some((request) => request.id === id)) { + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.responses`, + `${JSON.stringify({ id, result: message.result, error: message.error })}\n`, + ); + if (script.completeTurnOnServerResponse && activeTurn) { + write({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + threadId: script.rootThreadId, + turn: { ...activeTurn, status: "completed" }, + }, + }); + } + return; + } if (method === "initialize") { write({ id, @@ -48,6 +66,7 @@ rl.on("line", (line) => { const turn = turnId ? { ...fixture.responses.turnStart.turn, id: turnId } : fixture.responses.turnStart.turn; + activeTurn = turn; turnStartCount += 1; // Append-only sidecar the tests read to tell a steered follow-up (no new // turn) apart from one that opened a second provider turn. @@ -67,6 +86,9 @@ rl.on("line", (line) => { for (const notification of script.notifications) { write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); } + for (const request of script.serverRequests ?? []) { + write({ jsonrpc: "2.0", id: request.id, method: request.method, params: request.params }); + } if (script.holdTurnOpen !== true) { write({ jsonrpc: "2.0", diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 0bf15ad71c8f..b982bce8431e 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import type { OrchestrationProjectShell, ProjectId, @@ -2292,6 +2293,42 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o }), ); +it.effect("returns the refreshed listing on the first read after its cache expires", () => + Effect.gen(function* () { + let hostCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + hostCalls += 1; + return Effect.succeed({ + items: [changeRequest(hostCalls, "2026-07-02T00:00:00Z")], + truncated: false, + continues: false, + }); + }, + }), + ], + }); + + const first = yield* service.list({ state: "open" }); + assert.deepStrictEqual( + first.entries.map((entry) => entry.number), + [1], + ); + + yield* TestClock.adjust("31 seconds"); + const refreshed = yield* service.list({ state: "open" }); + + assert.strictEqual(hostCalls, 2); + assert.deepStrictEqual( + refreshed.entries.map((entry) => entry.number), + [2], + ); + }), +); + it.effect("a listing narrowed to some projects is its own cache entry", () => Effect.gen(function* () { const asked: ReadonlyArray[] = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 890465752ece..20762fe6c207 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -102,16 +102,7 @@ const DIFF_CACHE_TTL = Duration.seconds(60); const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); /** Sized like the client's own stale time; a row's counts move only when somebody pushes. */ const LIST_STATS_CACHE_TTL = Duration.seconds(60); -/** - * How long a cache's last success may still be served while a fresh read runs behind it. - * Bounded by how the page actually revalidates: clients re-read on mount and once a minute - * while open, and every one of those reads repopulates the cache in the background — so in - * steady use a "stale" answer is at most a refresh cycle old, and the window only stretches - * that far when nobody has looked at the page for minutes. An explicit refresh or a mutation - * bumps the epochs and skips held answers entirely. - */ -const LIST_STALE_WINDOW = Duration.minutes(10); -const DETAIL_STALE_WINDOW = Duration.minutes(5); +/** A diff can stay interactive while its next cached value is fetched off the critical path. */ const DIFF_STALE_WINDOW = Duration.minutes(10); /** How long one host's signed-in login is believed without asking its CLI again. */ const VIEWER_CACHE_TTL = Duration.minutes(10); @@ -1849,30 +1840,22 @@ export const make = Effect.gen(function* () { const runFork = Effect.runForkWith(context); /** - * Stale answers served while a fresh one is fetched behind them. Every read here leaves the - * process for a CLI whose wall clock is the host's — seconds on a good day, tens of them on a - * slow network — and the short cache windows below mean almost every page visit pays that - * clock again. The last success per key is therefore held a while longer: a read inside the - * window answers with it at once and refreshes the cache in the background, so the next read - * is fresh without anyone having waited on it. - * - * Correctness leans on the epochs: an explicit refresh or a mutation bumps them, the epoch is - * part of every key, and a held answer under the old key is simply never asked for again — so - * "give me truly fresh" still means exactly that. + * The diff is not live-polled and is expensive enough to keep its stale-while-revalidate path. + * Explicit refreshes and mutations still strand held values through the reference epoch. */ - const staleWhileRevalidate = (staleFor: Duration.Duration, capacity: number) => { - const staleMs = Duration.toMillis(staleFor); - const held = new Map(); - const record = (key: string, value: A) => + const staleDiff = (() => { + const staleMs = Duration.toMillis(DIFF_STALE_WINDOW); + const held = new Map(); + const record = (key: string, value: PullRequestDiffResult) => Effect.map(Clock.currentTimeMillis, (at) => { held.delete(key); - if (held.size >= capacity) { + if (held.size >= DIFF_CACHE_CAPACITY) { const oldest = held.keys().next().value; if (oldest !== undefined) held.delete(oldest); } held.set(key, { at, value }); }); - return (key: string, read: Effect.Effect): Effect.Effect => { + return (key: string, read: Effect.Effect) => { const recorded = read.pipe(Effect.tap((value) => record(key, value))); return Effect.flatMap(Clock.currentTimeMillis, (now) => { const snapshot = held.get(key); @@ -1883,7 +1866,7 @@ export const make = Effect.gen(function* () { return Effect.sync(() => runFork(Effect.ignore(recorded))).pipe(Effect.as(snapshot.value)); }); }; - }; + })(); // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the // epoch strands every entry made under the old one — no enumerating a cache whose keys @@ -1969,10 +1952,6 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_CACHE_TTL : Duration.zero), }, ); - const staleList = staleWhileRevalidate( - LIST_STALE_WINDOW, - LIST_CACHE_CAPACITY, - ); const list: PullRequestService["Service"]["list"] = (input) => { const key = JSON.stringify([ listingsEpoch, @@ -1999,7 +1978,7 @@ export const make = Effect.gen(function* () { ? null : Object.entries(input.cursors).toSorted(([left], [right]) => left.localeCompare(right)), ]); - return staleList(key, Cache.get(listCache, key)); + return Cache.get(listCache, key); }; const detailCache = yield* Cache.makeWith( @@ -2012,13 +1991,9 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); - const staleDetail = staleWhileRevalidate( - DETAIL_STALE_WINDOW, - DETAIL_CACHE_CAPACITY, - ); const detail: PullRequestService["Service"]["detail"] = (input) => { const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); - return staleDetail(key, Cache.get(detailCache, key)); + return Cache.get(detailCache, key); }; const activityCache = yield* Cache.makeWith( @@ -2031,13 +2006,9 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); - const staleActivity = staleWhileRevalidate( - DETAIL_STALE_WINDOW, - DETAIL_CACHE_CAPACITY, - ); const activity: PullRequestService["Service"]["activity"] = (input) => { const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); - return staleActivity(key, Cache.get(activityCache, key)); + return Cache.get(activityCache, key); }; const diffCache = yield* Cache.makeWith( @@ -2067,10 +2038,6 @@ export const make = Effect.gen(function* () { }, }, ); - const staleDiff = staleWhileRevalidate( - DIFF_STALE_WINDOW, - DIFF_CACHE_CAPACITY, - ); const diff: PullRequestService["Service"]["diff"] = (input) => { const key = JSON.stringify([ refEpoch(input), @@ -2099,10 +2066,6 @@ export const make = Effect.gen(function* () { // shares between clients like every other read. Refs are sorted so one page's worth of rows // is one key however the client assembled them, and the listings epoch rides along so the // refresh that forgets the listing forgets its decorations with it. - const staleListStats = staleWhileRevalidate( - LIST_STALE_WINDOW, - LIST_STATS_CACHE_CAPACITY, - ); const listStats: PullRequestService["Service"]["listStats"] = (input) => { if (input.refs.length === 0) return Effect.succeed({ stats: [] }); const key = JSON.stringify([ @@ -2113,7 +2076,7 @@ export const make = Effect.gen(function* () { `${left[0]} ${left[1]} ${left[2]}`.localeCompare(`${right[0]} ${right[1]} ${right[2]}`), ), ]); - return staleListStats(key, Cache.get(listStatsCache, key)); + return Cache.get(listStatsCache, key); }; const invalidate: PullRequestService["Service"]["invalidate"] = (input) => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index aa28bfd036ce..2889102d94db 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -125,6 +125,8 @@ import * as WorktreeLifecycle from "./orchestration/Services/WorktreeLifecycle.t import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; +import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -166,6 +168,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; @@ -403,6 +406,7 @@ const buildAppUnderTest = (options?: { layers?: { keybindings?: Partial; providerRegistry?: Partial; + providerService?: Partial; serverSettings?: Partial; externalLauncher?: Partial; vcsDriver?: Partial; @@ -419,6 +423,7 @@ const buildAppUnderTest = (options?: { >; terminalManager?: Partial; orchestrationEngine?: Partial; + analyticsService?: Partial; projectionSnapshotQuery?: Partial; worktreeLifecycle?: Partial; checkpointDiffQuery?: Partial; @@ -659,18 +664,24 @@ const buildAppUnderTest = (options?: { ), ), Layer.provide( - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([]), - refresh: () => Effect.succeed([]), - refreshInstance: () => Effect.succeed([]), - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), - ), - setProviderMaintenanceActionState: () => Effect.succeed([]), - streamChanges: Stream.empty, - ...options?.layers?.providerRegistry, - }), + Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + refresh: () => Effect.succeed([]), + refreshInstance: () => Effect.succeed([]), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + ), + setProviderMaintenanceActionState: () => Effect.succeed([]), + streamChanges: Stream.empty, + ...options?.layers?.providerRegistry, + }), + Layer.mock(ProviderService.ProviderService)({ + uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"), + ...options?.layers?.providerService, + }), + ), ), Layer.provide( Layer.mock(ServerSettings.ServerSettingsService)({ @@ -909,6 +920,13 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), Layer.provide(UsageService.layerTest), + Layer.provide( + Layer.mock(AnalyticsService.AnalyticsService)({ + record: () => Effect.void, + flush: Effect.void, + ...options?.layers?.analyticsService, + }), + ), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, @@ -4602,6 +4620,114 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("uploads Codex thread feedback through websocket rpc", () => + Effect.gen(function* () { + const input = { + threadId: ThreadId.make("thread-feedback"), + reason: "The agent stopped early.", + }; + const uploadFeedback = vi.fn( + () => Effect.succeed({ feedbackId: "codex-thread-feedback" }), + ); + yield* buildAppUnderTest({ + layers: { + providerService: { uploadFeedback }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.providerUploadFeedback](input)), + ); + + assert.deepStrictEqual(response, { feedbackId: "codex-thread-feedback" }); + assert.deepStrictEqual(uploadFeedback.mock.calls, [[input]]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("uploads image bytes through a signed URL issued by websocket rpc", () => + Effect.gen(function* () { + const config = yield* buildAppUnderTest(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const wsUrl = yield* getWsServerUrl("/ws"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const issued = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }); + const rejected = yield* HttpClient.post(issued.relativeUrl, { + body: HttpBody.uint8Array(new Uint8Array([1, 2, 3]), "image/png"), + }); + assert.equal(rejected.status, 400); + + const response = yield* HttpClient.post(issued.relativeUrl, { + headers: { origin: crossOriginClientOrigin }, + body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6]), "image/png"), + }); + assert.equal(response.status, 204); + assertBrowserApiCorsResponseHeaders(response.headers); + + const attachmentPath = path.join(config.attachmentsDir, `${issued.attachmentId}.png`); + assert.isTrue(yield* fileSystem.exists(attachmentPath)); + + yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: issued.attachmentId }); + assert.isFalse(yield* fileSystem.exists(attachmentPath)); + + const streamed = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + name: "streamed.png", + mimeType: "image/png", + sizeBytes: 6, + }); + const streamedResponse = yield* HttpClient.post(streamed.relativeUrl, { + body: HttpBody.stream(Stream.make(new Uint8Array([1, 2, 3, 4, 5, 6])), "image/png"), + }); + assert.equal(streamedResponse.status, 204); + yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: streamed.attachmentId }); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("keeps feedback errors structured across websocket rpc", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-feedback-failure"); + yield* buildAppUnderTest({ + layers: { + providerService: { + uploadFeedback: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "feedback/upload", + detail: "private provider detail", + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const error = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.providerUploadFeedback]({ threadId }).pipe(Effect.flip), + ), + ); + + assert.strictEqual(error._tag, "ProviderUploadFeedbackError"); + if (error._tag === "ProviderUploadFeedbackError") { + assert.strictEqual(error.threadId, threadId); + assert.strictEqual(error.message, `Failed to upload feedback for thread ${threadId}.`); + assert.isDefined(error.cause); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("shares one preview automation broker across websocket sessions", () => Effect.scoped( Effect.gen(function* () { @@ -5194,6 +5320,101 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("records thread analytics only after a client command succeeds", () => + Effect.gen(function* () { + const effects: string[] = []; + const analyticsProperties: Array> | undefined> = []; + const failedCommandId = CommandId.make("cmd-thread-create-failed"); + + yield* buildAppUnderTest({ + layers: { + analyticsService: { + record: (event, properties) => + Effect.sync(() => { + effects.push(`analytics:${event}`); + analyticsProperties.push(properties); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => effects.push(`dispatch:${command.commandId}`)).pipe( + Effect.flatMap(() => + command.commandId === failedCommandId + ? Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "thread creation failed", + }), + ) + : Effect.succeed({ sequence: 1 }), + ), + ), + }, + }, + }); + + const createThreadCommand = (commandId: CommandId, threadId: ThreadId) => + ({ + type: "thread.create", + commandId, + threadId, + projectId: defaultProjectId, + title: "Analytics test", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }) as const; + + const wsUrl = yield* getWsServerUrl( + "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro", + ); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const failed = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( + createThreadCommand(failedCommandId, ThreadId.make("thread-create-failed")), + ).pipe(Effect.result); + + assert.equal(failed._tag, "Failure"); + assert.deepEqual(effects, [ + "analytics:client.connected", + "dispatch:cmd-thread-create-failed", + ]); + + const succeeded = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( + createThreadCommand( + CommandId.make("cmd-thread-create-succeeded"), + ThreadId.make("thread-create-succeeded"), + ), + ); + + assert.equal(succeeded.sequence, 1); + }), + ), + ); + + assert.deepEqual(effects, [ + "analytics:client.connected", + "dispatch:cmd-thread-create-failed", + "dispatch:cmd-thread-create-succeeded", + "analytics:client.thread.started", + ]); + assert.deepEqual(analyticsProperties, [ + { + surface: "mobile", + appVersion: "1.2.3", + os: "iOS", + osMajorVersion: 18, + deviceModel: "iPhone 15 Pro", + }, + { surface: "mobile", appVersion: "1.2.3" }, + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile errors", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -8814,12 +9035,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("preserves created bootstrap threads when worktree creation defects", () => Effect.gen(function* () { const dispatchedCommands: Array = []; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const createWorktree = vi.fn( (_: Parameters[0]) => Effect.die(new Error("worktree exploded")), ); - yield* buildAppUnderTest({ + const config = yield* buildAppUnderTest({ layers: { gitVcsDriver: { createWorktree, @@ -8835,16 +9058,127 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + let pendingAttachmentId: string | undefined; + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const upload = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }); + pendingAttachmentId = upload.attachmentId; + const uploadResponse = yield* HttpClient.post(upload.relativeUrl, { + body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6]), "image/png"), + }); + assert.equal(uploadResponse.status, 204); + + return yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-defect"), + threadId: ThreadId.make("thread-bootstrap-defect"), + message: { + messageId: MessageId.make("msg-bootstrap-defect"), + role: "user", + text: "hello", + attachments: [ + { + type: "image", + id: upload.attachmentId, + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }, + ], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: false, + }, + createdAt, + }); + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(result.failure.message, "worktree exploded"); + assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.delete"], + ); + assert.isDefined(pendingAttachmentId); + assert.isTrue( + yield* fileSystem.exists(path.join(config.attachmentsDir, `${pendingAttachmentId}.png`)), + ); + assert.deepEqual(yield* fileSystem.readDirectory(config.attachmentsDir), [ + `${pendingAttachmentId}.png`, + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not report a deleted bootstrap thread when cleanup fails", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("worktree exploded")), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => { + dispatchedCommands.push(command); + if (command.type === "thread.delete") { + return Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "thread cleanup exploded", + }), + ); + } + return Effect.succeed({ sequence: dispatchedCommands.length }); + }, + readEvents: () => Stream.empty, + }, + }, + }); + const createdAt = "2026-01-01T00:00:00.000Z"; const wsUrl = yield* getWsServerUrl("/ws"); const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.start", - commandId: CommandId.make("cmd-bootstrap-turn-start-defect"), - threadId: ThreadId.make("thread-bootstrap-defect"), + commandId: CommandId.make("cmd-bootstrap-turn-start-cleanup-defect"), + threadId: ThreadId.make("thread-bootstrap-cleanup-defect"), message: { - messageId: MessageId.make("msg-bootstrap-defect"), + messageId: MessageId.make("msg-bootstrap-cleanup-defect"), role: "user", text: "hello", attachments: [], @@ -8878,9 +9212,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertTrue(result._tag === "Failure"); assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); assert.include(result.failure.message, "worktree exploded"); + assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create"], + ["thread.create", "thread.delete"], ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e0c00411239f..baf8e9d5404c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -11,6 +11,7 @@ import * as ServerConfig from "./config.ts"; import { otlpTracesProxyRouteLayer, assetRouteLayer, + attachmentUploadRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, @@ -166,7 +167,10 @@ const ApplicationObservabilityLive = ObservabilityLive.pipe( Layer.provideMerge(ResourceAttributionLayerLive), ); -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), @@ -587,6 +591,7 @@ export const makeRoutesLayer = Layer.mergeAll( ), otlpTracesProxyRouteLayer, assetRouteLayer, + attachmentUploadRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts new file mode 100644 index 000000000000..02919b078a16 --- /dev/null +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -0,0 +1,298 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + type OrchestrationCommand, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { OrchestrationCommandInvariantError } from "./orchestration/Errors.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderSessionDirectoryPersistenceError } from "./provider/Errors.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; + +const providerInstanceId = ProviderInstanceId.make("codex"); +const updatedAt = "2026-08-20T12:00:00.000Z"; + +const makeThread = ( + id: string, + status: "starting" | "running" | "ready" | "stopped" | "error", + activeTurnId: TurnId | null = null, + archivedAt: string | null = null, +) => ({ + id: ThreadId.make(id), + archivedAt, + deletedAt: null, + session: { + threadId: ThreadId.make(id), + status, + providerName: "codex" as const, + providerInstanceId, + runtimeMode: "full-access" as const, + activeTurnId, + lastError: null, + updatedAt, + }, +}); + +const makeProviderService = (liveThreadIds: ReadonlyArray = []) => + ({ + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + compactSession: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: () => Effect.die("unused"), + listSessions: () => Effect.succeed(liveThreadIds.map((threadId) => ({ threadId }) as never)), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + uploadFeedback: () => Effect.die("unused"), + streamEvents: Stream.empty, + }) satisfies ProviderService.ProviderService["Service"]; + +const queryWithThreads = (threads: ReadonlyArray>) => + ({ + getCommandReadModel: () => Effect.succeed({ threads } as never), + }) as unknown as ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + +const runReconciliation = (input: { + readonly threads: ReadonlyArray>; + readonly liveThreadIds?: ReadonlyArray; + readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + readonly dispatch: OrchestrationEngine.OrchestrationEngineService["Service"]["dispatch"]; +}) => + ServerRuntimeStartup.reconcileProviderSessions.pipe( + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + queryWithThreads(input.threads), + ), + Effect.provideService( + ProviderService.ProviderService, + makeProviderService(input.liveThreadIds), + ), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: input.dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Effect.provide(NodeServices.layer), + ); + +it.effect("reconciles multiple active and archived orphans but skips live sessions", () => { + const starting = makeThread("thread-starting", "starting"); + const running = makeThread("thread-running", "running", TurnId.make("turn-running")); + const staleActiveTurn = makeThread( + "thread-stale-active-turn", + "ready", + TurnId.make("turn-stale-active"), + ); + const archived = makeThread( + "thread-archived", + "running", + TurnId.make("turn-archived"), + updatedAt, + ); + const live = makeThread("thread-live", "running", TurnId.make("turn-live")); + const settled = makeThread("thread-ready", "ready"); + const dispatched: OrchestrationCommand[] = []; + const bindingReads: ThreadId[] = []; + const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = []; + + return runReconciliation({ + threads: [starting, running, staleActiveTurn, archived, live, settled], + liveThreadIds: [live.id], + directory: { + getBinding: (candidate) => + Effect.sync(() => bindingReads.push(candidate)).pipe( + Effect.as( + Option.some({ + threadId: candidate, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running" as const, + resumeCursor: { cursor: candidate }, + runtimePayload: { activeTurnId: "stale", unrelated: candidate }, + }), + ), + ), + upsert: (binding) => Effect.sync(() => upserts.push(binding)), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }, + dispatch: (command) => + Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length })), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + const orphanIds = [starting.id, running.id, staleActiveTurn.id, archived.id]; + assert.deepStrictEqual(bindingReads, orphanIds); + assert.deepStrictEqual( + dispatched.map((command) => command.type === "thread.session.set" && command.threadId), + orphanIds, + ); + assert.deepStrictEqual( + dispatched.map((command) => + command.type === "thread.session.set" + ? { + status: command.session.status, + activeTurnId: command.session.activeTurnId, + } + : null, + ), + orphanIds.map(() => ({ status: "error" as const, activeTurnId: null })), + ); + assert.equal(upserts.length, orphanIds.length); + for (const binding of upserts) { + assert.equal(binding.status, "stopped"); + assert.deepStrictEqual(binding.runtimePayload, { activeTurnId: null }); + assert.deepStrictEqual(binding.resumeCursor, { cursor: binding.threadId }); + } + }), + ), + ); +}); + +it.effect( + "settles projections when directory bindings are absent, corrupt, or fail to upsert", + () => { + const absent = makeThread("thread-binding-absent", "starting"); + const corrupt = makeThread("thread-binding-corrupt", "running"); + const upsertFailure = makeThread("thread-binding-upsert-failure", "running"); + const dispatched: OrchestrationCommand[] = []; + const corruptFailure = new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.getBinding", + detail: "corrupt persisted binding", + }); + const writeFailure = new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.upsert", + detail: "failed binding write", + }); + + return runReconciliation({ + threads: [absent, corrupt, upsertFailure], + directory: { + getBinding: (candidate) => + candidate === absent.id + ? Effect.succeed(Option.none()) + : candidate === corrupt.id + ? Effect.fail(corruptFailure) + : Effect.succeed( + Option.some({ + threadId: candidate, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + }), + ), + upsert: () => Effect.fail(writeFailure), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }, + dispatch: (command) => + Effect.sync(() => dispatched.push(command)).pipe( + Effect.as({ sequence: dispatched.length }), + ), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + assert.deepStrictEqual( + dispatched.map((command) => command.type === "thread.session.set" && command.threadId), + [absent.id, corrupt.id, upsertFailure.id], + ); + }), + ), + ); + }, +); + +it.effect("retries failed projections and continues after a persistent failure", () => { + const transient = makeThread("thread-dispatch-transient-failure", "running"); + const persistent = makeThread("thread-dispatch-persistent-failure", "running"); + const later = makeThread("thread-dispatch-success", "running"); + const attempted: ThreadId[] = []; + let transientAttempts = 0; + const failure = new OrchestrationCommandInvariantError({ + commandType: "thread.session.set", + detail: "simulated startup reconciliation failure", + }); + + return runReconciliation({ + threads: [transient, persistent, later], + directory: { + getBinding: () => Effect.succeed(Option.none()), + upsert: () => Effect.void, + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }, + dispatch: (command) => { + if (command.type !== "thread.session.set") { + return Effect.die("unexpected command"); + } + attempted.push(command.threadId); + if (command.threadId === transient.id && transientAttempts++ === 0) { + return Effect.fail(failure); + } + return command.threadId === persistent.id + ? Effect.fail(failure) + : Effect.succeed({ sequence: attempted.length }); + }, + }).pipe( + Effect.tap(() => + Effect.sync(() => + assert.deepStrictEqual(attempted, [ + transient.id, + transient.id, + persistent.id, + persistent.id, + later.id, + ]), + ), + ), + ); +}); + +it.effect("does not fail startup when the live provider session inventory cannot be read", () => { + let queried = false; + return ServerRuntimeStartup.reconcileProviderSessions.pipe( + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.sync(() => { + queried = true; + return { threads: [] } as never; + }), + } as unknown as ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]), + Effect.provideService(ProviderService.ProviderService, { + ...makeProviderService(), + listSessions: () => Effect.die("provider inventory unavailable"), + }), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { + getBinding: () => Effect.die("unused"), + upsert: () => Effect.die("unused"), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Effect.provide(NodeServices.layer), + Effect.tap(() => Effect.sync(() => assert.equal(queried, false))), + ); +}); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 46d1dd74cdc4..b6624a5477f4 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -8,6 +8,7 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -40,6 +41,8 @@ import * as ServerSettings from "./serverSettings.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; import * as OrphanSessionRecovery from "./orchestration/Services/OrphanSessionRecovery.ts"; import { forkParked } from "./serverActivation.ts"; @@ -329,6 +332,89 @@ export function interruptSessionAfterServerRestart( }; } +const ORPHANED_PROVIDER_SESSION_ERROR = + "Provider session did not survive a server restart. Send a new message to continue."; + +export const reconcileProviderSessions = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const providerService = yield* ProviderService.ProviderService; + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + + const liveThreadIds = new Set( + (yield* providerService.listSessions()).map((session) => session.threadId), + ); + const { threads } = yield* query.getCommandReadModel(); + const orphanedThreads = threads.filter( + (thread) => + thread.session !== null && + (thread.session.status === "starting" || + thread.session.status === "running" || + thread.session.activeTurnId !== null) && + !liveThreadIds.has(thread.id), + ); + + for (const thread of orphanedThreads) { + const session = thread.session; + if (session === null) { + continue; + } + yield* Effect.gen(function* () { + const binding = yield* directory.getBinding(thread.id); + if (Option.isSome(binding)) { + yield* directory.upsert({ + ...binding.value, + status: "stopped", + runtimePayload: { activeTurnId: null }, + }); + } + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("failed to reconcile orphaned provider session directory binding", { + threadId: thread.id, + cause, + }), + ), + ); + + yield* Effect.gen(function* () { + const reconciledAt = DateTime.formatIso(yield* DateTime.now); + yield* orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId: thread.id, + session: { + ...session, + status: "error", + activeTurnId: null, + lastError: ORPHANED_PROVIDER_SESSION_ERROR, + updatedAt: reconciledAt, + }, + createdAt: reconciledAt, + }); + }).pipe( + Effect.retry({ times: 1 }), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("failed to settle orphaned provider session projection", { + threadId: thread.id, + cause, + }), + ), + ); + } +}).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("provider session startup reconciliation failed", { cause }), + ), +); + interface StartupOptions { readonly activate?: Effect.Effect; readonly awaitAuxiliaryParked?: Effect.Effect; @@ -429,6 +515,8 @@ export const make = (options?: StartupOptions) => }), ); + yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions); + const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 35ef5e976223..521865839bde 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, @@ -487,6 +531,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 +870,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 +923,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 +960,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/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 00fb4e4106df..68f3c346759d 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -27,6 +27,7 @@ import { SERVICE_STATE_FILE, SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; +import { isEntrypoint } from "./entrypoint.ts"; const HANDOFF_DELAY_MS = 2_000; const PREPARED_TIMEOUT_MS = 120_000; @@ -611,7 +612,13 @@ async function main(): Promise { await new Launcher(baseDir, state).run(); } -if (import.meta.main) { +if ( + isEntrypoint({ + moduleUrl: import.meta.url, + entryPath: process.argv[1], + runtimeMain: import.meta.main, + }) +) { main().catch((cause: unknown) => { const error = cause instanceof Error ? cause : new Error(String(cause)); process.stderr.write(`[service-launcher] ${error.message}\n`); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index e1fc3ca51265..a5a1682712ca 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -291,6 +291,10 @@ export class GitVcsDriver extends Context.Service< ) => Effect.Effect; readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + readonly resolveDefaultBranchName: ( + cwd: string, + remoteName: string, + ) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( @@ -308,6 +312,10 @@ export class GitVcsDriver extends Context.Service< readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; + /** Drops worktree admin entries whose directory is already gone (`git worktree prune`). */ + readonly pruneWorktrees: (input: { + readonly cwd: string; + }) => Effect.Effect; readonly renameBranch: ( input: GitRenameBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index ae848755168c..3d3e10ec6e62 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -694,13 +694,13 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); const pathService = yield* Path.Path; - const missingWorktree = pathService.join(cwd, "missing-worktree"); + const fileSystem = yield* FileSystem.FileSystem; + const notAWorktree = pathService.join(cwd, "not-a-worktree"); + yield* fileSystem.makeDirectory(notAWorktree); const driver = yield* GitVcsDriver.GitVcsDriver; yield* driver.initRepo({ cwd }); - const error = yield* driver - .removeWorktree({ cwd, path: missingWorktree }) - .pipe(Effect.flip); + const error = yield* driver.removeWorktree({ cwd, path: notAWorktree }).pipe(Effect.flip); assert.deepInclude(error, { _tag: "GitCommandError", @@ -710,9 +710,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { cwd, }); assert.notProperty(error, "cause"); + assert.notProperty(error, "stderr"); assert.notInclude(error.detail, "Git command failed in"); }), ); + + it.effect("treats removing an already-gone worktree as a no-op", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const missingWorktree = pathService.join(cwd, "missing-worktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + + yield* driver.removeWorktree({ cwd, path: missingWorktree }); + }), + ); }); describe("review diff previews", () => { @@ -1329,6 +1342,92 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("checks out submodules in a new worktree", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + // Git refuses `file:` submodule transports by default (CVE-2022-39253) + // and ignores repo-level config for it, so a local fixture needs the + // env allowance. Real submodules are https/ssh and need none of this. + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // A real submodule: `git worktree add` leaves these empty, which is + // what silently strips shared tooling out of every new worktree. + const submoduleRepo = yield* makeTmpDir("git-submodule-"); + yield* initRepoWithCommit(submoduleRepo); + yield* writeTextFile(submoduleRepo, "SHARED.md", "# shared\n"); + yield* git(submoduleRepo, ["add", "."]); + yield* git(submoduleRepo, ["commit", "-m", "shared"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "submodule-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/submodules", + }); + + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "shared", "SHARED.md")), + true, + ); + }), + ); + + it.effect("still creates the worktree when submodule checkout fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + // Points at a repository that does not exist, so the checkout fails the + // way an unreachable private remote would. Creation must still succeed. + yield* writeTextFile( + cwd, + ".gitmodules", + '[submodule "missing"]\n\tpath = missing\n\turl = /nonexistent/repo.git\n', + ); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add unreachable submodule"]); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "broken-submodule-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/broken-submodules", + }); + + assert.equal(created.worktree.path, worktreePath); + assert.equal(yield* fileSystem.exists(worktreePath), true); + }), + ); + it.effect("creates and removes a worktree for a new refName", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1551,6 +1650,57 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(yield* fileSystem.readFileString(preparedFile), "prepared"); }), ); + + it.effect("removes the same worktree path twice without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "shared"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/shared", + }); + + // Two threads can record the same worktree path; the second delete + // must be a no-op instead of exit 128. + yield* driver.removeWorktree({ cwd, path: worktreePath }); + yield* driver.removeWorktree({ cwd, path: worktreePath }); + }), + ); + + it.effect("prunes stale registrations when removing an already-gone worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreesRoot = yield* makeTmpDir("git-worktrees-"); + const stalePath = pathService.join(worktreesRoot, "stale"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: stalePath, + refName: initialBranch, + newRefName: "feature/stale", + }); + // Delete the directory behind git's back so the registration goes stale. + yield* fileSystem.remove(stalePath, { recursive: true }); + + yield* driver.removeWorktree({ + cwd, + path: pathService.join(worktreesRoot, "never-registered"), + }); + + const registered = yield* git(cwd, ["worktree", "list", "--porcelain"]); + assert.notInclude(registered, "stale"); + }), + ); }); describe("remote operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index d97915a4c550..28c1979bf564 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -40,6 +40,10 @@ import { import { ServerConfig } from "../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +// `git worktree add` checks out the full tree, so on large repositories it can +// take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle +// machine). Give it generous headroom while still bounding a genuinely hung git. +const WORKTREE_ADD_TIMEOUT_MS = 300_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; @@ -507,6 +511,17 @@ function isUnbornHeadStderr(stderr: string): boolean { ); } +// Matches `git worktree remove` on a path git no longer tracks: "is not a +// working tree" when the registration is gone, "cannot remove working tree" +// when older gits fail validation on a registered-but-deleted directory. +function isMissingWorktreeStderr(stderr: string): boolean { + const normalized = stderr.toLowerCase(); + return ( + normalized.includes("is not a working tree") || + normalized.includes("cannot remove working tree") + ); +} + interface Trace2Monitor { readonly env: NodeJS.ProcessEnv; readonly flush: Effect.Effect; @@ -2832,6 +2847,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : undefined; yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { fallbackErrorDetail: "git worktree add failed", + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, ...(preparationEnv === undefined ? {} : { env: preparationEnv }), }); @@ -2914,6 +2930,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } + // `git worktree add` leaves submodules empty, so a repo that keeps agent + // skills, tooling or source in one gets a worktree that is quietly missing + // them. Best-effort: the objects are usually already in the parent's + // `.git/modules`, but a first-ever clone needs the network, and failing to + // populate a submodule must not roll back the caller's thread. + const hasSubmodules = yield* fileSystem + .exists(path.join(worktreePath, ".gitmodules")) + .pipe(Effect.orElseSucceed(() => false)); + if (hasSubmodules) { + yield* runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, [ + "submodule", + "update", + "--init", + "--recursive", + ]).pipe( + Effect.catch((cause) => + Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { + worktreePath, + cause, + }), + ), + ); + } + if (input.newRefName && input.baseRefName) { const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); const parsedBaseRef = parseRemoteRefWithRemoteNames( @@ -3126,9 +3166,47 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* args.push("--force"); } args.push(input.path); - yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { + const result = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.removeWorktree", + input.cwd, + args, + { timeoutMs: 15_000, allowNonZeroExit: true }, + ); + if (result.exitCode === 0) { + return; + } + // Threads can share a worktree path, and worktrees get removed or pruned + // outside the app, so a worktree that is already gone is a no-op rather + // than an error. Prune so no stale registration lingers to block a later + // `worktree add` at the same path. + const alreadyGone = + isMissingWorktreeStderr(result.stderr) && + !(yield* fileSystem.exists(input.path).pipe(Effect.orElseSucceed(() => false))); + if (alreadyGone) { + yield* pruneWorktrees({ cwd: input.cwd }); + return; + } + // Raw stderr stays out of both the wire error and the log (it can carry + // secrets); log bounded diagnostics so a genuine failure is visible + // server-side. + yield* Effect.logWarning( + `GitVcsDriver.removeWorktree: git worktree remove exited with code ${result.exitCode} for ${input.path} (stderr length ${result.stderr.length}).`, + ); + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.removeWorktree", cwd: input.cwd, args }), + detail: "git worktree remove failed", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); + }); + + const pruneWorktrees: GitVcsDriver.GitVcsDriver["Service"]["pruneWorktrees"] = Effect.fn( + "pruneWorktrees", + )(function* (input) { + yield* executeGit("GitVcsDriver.pruneWorktrees", input.cwd, ["worktree", "prune"], { timeoutMs: 15_000, - fallbackErrorDetail: "git worktree remove failed", + fallbackErrorDetail: "git worktree prune failed", }); }); @@ -3321,6 +3399,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, + resolveDefaultBranchName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), remoteExists, resolveRemoteTrackingCommit, @@ -3329,6 +3408,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), + pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)), renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index dd19541f8947..81b82c62a0cf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -19,9 +19,11 @@ import { type AiUsageSnapshot, type AuthEnvironmentScope, AuthSessionId, + ClientSurface, CommandId, type DiscoveredLocalServerList, EventId, + type OrchestrationClientOrigin, type OrchestrationCommand, type GitActionProgressEvent, type GitManagerServiceError, @@ -46,6 +48,7 @@ import { ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, + ProviderUploadFeedbackError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, type ServerSelfUpdateError, @@ -74,7 +77,10 @@ import { projectActivityEvent, projectThreadDetailSnapshot, } from "./orchestration/ActivityPayloadProjection.ts"; -import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; +import { + cleanupFailedUploadedAttachments, + normalizeDispatchCommand, +} from "./orchestration/Normalizer.ts"; import { GrokTranscriptResync } from "./externalSessions/GrokTranscriptResync.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -85,6 +91,7 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -95,6 +102,7 @@ import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortExposure from "./preview/PortExposure.ts"; +import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as AiUsageMonitorModule from "./aiUsage/AiUsageMonitor.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; @@ -117,6 +125,7 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as HostResourceProbe from "./diagnostics/HostResourceProbe.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -394,8 +403,60 @@ function toAuthAccessStreamEvent( } } +const isClientSurface = Schema.is(ClientSurface); +const MAX_CLIENT_APP_VERSION_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 +// never fails over attribution metadata. +function readClientConnectionOrigin( + request: HttpServerRequest.HttpServerRequest, +): OrchestrationClientOrigin { + 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() ?? ""; + return { + ...(isClientSurface(surface) ? { surface } : {}), + ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH + ? { appVersion } + : {}), + }; +} + +const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({ + ...(origin.surface !== undefined ? { surface: origin.surface } : {}), + ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}), +}); + +function readMobileDeviceAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url) || url.value.searchParams.get("clientSurface") !== "mobile") { + return {}; + } + + const os = url.value.searchParams.get("clientOs"); + const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? ""; + const osMajorVersion = Number(rawOsMajorVersion); + const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? ""; + + return { + ...(os === "iOS" || os === "Android" ? { os } : {}), + ...(rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0 + ? { osMajorVersion } + : {}), + ...(deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH + ? { deviceModel } + : {}), + }; +} + const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, + clientOrigin: OrchestrationClientOrigin, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], productHandshakeValid: boolean, ) => @@ -406,6 +467,35 @@ const makeWsRpcLayer = ( const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const grokTranscriptResync = yield* GrokTranscriptResync; + const analytics = yield* AnalyticsService.AnalyticsService; + // Every command dispatched on this connection carries the connecting + // client's origin, including server-generated bootstrap sub-commands: + // the client's request caused them. + const hasClientOrigin = + clientOrigin.surface !== undefined || clientOrigin.appVersion !== undefined; + const dispatchFromClient: OrchestrationEngine.OrchestrationEngineShape["dispatch"] = ( + command, + ) => + orchestrationEngine.dispatch( + 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); + 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.turn.requested", originProps); + default: + return Effect.void; + } + }; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; @@ -421,6 +511,7 @@ const makeWsRpcLayer = ( const portExposure = yield* PortExposure.PreviewPortExposure; const aiUsageMonitor = yield* AiUsageMonitorModule.AiUsageMonitor; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const providerService = yield* ProviderService.ProviderService; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; @@ -579,7 +670,7 @@ const makeWsRpcLayer = ( activityId: serverEventId, }).pipe( Effect.flatMap(({ commandId, activityId }) => - orchestrationEngine.dispatch({ + dispatchFromClient({ type: "thread.activity.append", commandId, threadId: input.threadId, @@ -831,6 +922,20 @@ const makeWsRpcLayer = ( let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; + const cleanupCreatedThread = () => + createdThread + ? serverCommandId("bootstrap-thread-delete").pipe( + Effect.flatMap((commandId) => + dispatchFromClient({ + type: "thread.delete", + commandId, + threadId: command.threadId, + }), + ), + Effect.as(true), + ) + : Effect.succeed(false); + const recordSetupScriptLaunchFailure = (input: { readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError; readonly requestedAt: string; @@ -1004,44 +1109,42 @@ const makeWsRpcLayer = ( const bootstrapProgram = Effect.gen(function* () { if (bootstrap?.createThread) { - createdThread = yield* orchestrationEngine - .dispatch({ - type: "thread.create", - commandId: yield* serverCommandId("bootstrap-thread-create"), - threadId: command.threadId, - projectId: bootstrap.createThread.projectId, - title: bootstrap.createThread.title, - modelSelection: bootstrap.createThread.modelSelection, - runtimeMode: bootstrap.createThread.runtimeMode, - interactionMode: bootstrap.createThread.interactionMode, - branch: bootstrap.createThread.branch, - worktreePath: bootstrap.createThread.worktreePath, - createdAt: bootstrap.createThread.createdAt, - }) - .pipe( - Effect.as(true), - Effect.catch((createError) => { - if ( - createError._tag !== "OrchestrationCommandInvariantError" || - !createError.detail.includes("already exists") - ) { - return Effect.fail(createError); - } - return projectionSnapshotQuery.getThreadShellById(command.threadId).pipe( - Effect.matchEffect({ - onFailure: () => Effect.fail(createError), - onSuccess: Option.match({ - // A reconnect can replay the bootstrap after its - // thread.create committed but before the turn-start - // response reached the client. Resume the remaining - // bootstrap instead of rejecting the duplicate. - onSome: () => Effect.succeed(false), - onNone: () => Effect.fail(createError), - }), + createdThread = yield* dispatchFromClient({ + type: "thread.create", + commandId: yield* serverCommandId("bootstrap-thread-create"), + threadId: command.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode, + interactionMode: bootstrap.createThread.interactionMode, + branch: bootstrap.createThread.branch, + worktreePath: bootstrap.createThread.worktreePath, + createdAt: bootstrap.createThread.createdAt, + }).pipe( + Effect.as(true), + Effect.catch((createError) => { + if ( + createError._tag !== "OrchestrationCommandInvariantError" || + !createError.detail.includes("already exists") + ) { + return Effect.fail(createError); + } + return projectionSnapshotQuery.getThreadShellById(command.threadId).pipe( + Effect.matchEffect({ + onFailure: () => Effect.fail(createError), + onSuccess: Option.match({ + // A reconnect can replay the bootstrap after its + // thread.create committed but before the turn-start + // response reached the client. Resume the remaining + // bootstrap instead of rejecting the duplicate. + onSome: () => Effect.succeed(false), + onNone: () => Effect.fail(createError), }), - ); - }), - ); + }), + ); + }), + ); } if (bootstrap?.prepareWorktree && !(bootstrap.createThread && createdThread)) { @@ -1159,7 +1262,7 @@ const makeWsRpcLayer = ( path: null, }); targetWorktreePath = worktree.worktree.path; - yield* orchestrationEngine.dispatch({ + yield* dispatchFromClient({ type: "thread.meta.update", commandId: yield* serverCommandId("bootstrap-thread-meta-update"), threadId: command.threadId, @@ -1173,13 +1276,39 @@ const makeWsRpcLayer = ( yield* runSetupProgram(); - return yield* orchestrationEngine.dispatch(finalTurnStartCommand); + return yield* dispatchFromClient(finalTurnStartCommand); }); // thread.created is externally visible once dispatched. Preserve that // thread when later bootstrap work fails so the client can show and retry it. return yield* bootstrapProgram.pipe( - Effect.catchCause((cause) => Effect.fail(toBootstrapDispatchCommandCauseError(cause))), + Effect.catchCause((cause) => { + const dispatchError = toBootstrapDispatchCommandCauseError(cause); + if (Cause.hasInterruptsOnly(cause)) { + return Effect.fail(dispatchError); + } + return Effect.uninterruptible(cleanupCreatedThread()).pipe( + Effect.matchCauseEffect({ + onFailure: (cleanupCause) => + Effect.logWarning("bootstrap thread cleanup failed", { + threadId: command.threadId, + detail: Cause.pretty(cleanupCause), + }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), + onSuccess: (threadDeleted) => + Effect.fail( + threadDeleted + ? new OrchestrationDispatchCommandError({ + message: dispatchError.message, + ...(dispatchError.cause !== undefined + ? { cause: dispatchError.cause } + : {}), + bootstrapThreadDisposition: "deleted", + }) + : dispatchError, + ), + }), + ); + }), ); }); @@ -1189,13 +1318,11 @@ const makeWsRpcLayer = ( const dispatchEffect = normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap ? dispatchBootstrapTurnStart(normalizedCommand) - : orchestrationEngine - .dispatch(normalizedCommand) - .pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), - ), - ); + : dispatchFromClient(normalizedCommand).pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); return startup .enqueueCommand(dispatchEffect) @@ -1331,6 +1458,9 @@ const makeWsRpcLayer = ( ), ) : false; + const dispatchWithCleanup = dispatchNormalizedCommand(normalizedCommand).pipe( + Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)), + ); // Unarchive restores a missing worktree from the retained // branch before the command commits; a failed restoration // leaves the thread archived instead of silently detaching it @@ -1340,7 +1470,7 @@ const makeWsRpcLayer = ( ? yield* worktreeLifecycle .restoreThreadWorktree( { threadId: normalizedCommand.threadId }, - dispatchNormalizedCommand(normalizedCommand), + dispatchWithCleanup, ) .pipe( Effect.mapError((error) => @@ -1350,7 +1480,8 @@ const makeWsRpcLayer = ( ), ), ) - : yield* dispatchNormalizedCommand(normalizedCommand); + : yield* dispatchWithCleanup; + yield* recordClientCommandAnalytics(normalizedCommand); if (parkingCommand) { const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; if (shouldStopSessionAfterCommand) { @@ -1768,6 +1899,20 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.providerUploadFeedback]: (input) => + observeRpcEffect( + WS_METHODS.providerUploadFeedback, + providerService.uploadFeedback(input).pipe( + Effect.mapError( + (cause) => + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause, + }), + ), + ), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, @@ -2207,6 +2352,16 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.attachmentsCreateUploadUrl]: (input) => + observeRpcEffect(WS_METHODS.attachmentsCreateUploadUrl, issueAttachmentUploadUrl(input), { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.attachmentsDelete]: (input) => + observeRpcEffect( + WS_METHODS.attachmentsDelete, + deletePendingAttachment(input.attachmentId), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, @@ -2679,6 +2834,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const analytics = yield* AnalyticsService.AnalyticsService; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), @@ -2693,11 +2849,22 @@ export const websocketRpcRouteLayer = Layer.unwrap( isValidOmegentT3ProductHandshake( parseProductHandshakeFromSearchParams(requestUrl.value.searchParams), ); + const clientOrigin = readClientConnectionOrigin(request); + yield* sessions.recordClientConnection(session.sessionId, clientOrigin); + yield* analytics.record("client.connected", { + ...clientOriginAnalyticsProps(clientOrigin), + ...readMobileDeviceAnalyticsProps(request), + }); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, previewAutomationBroker, productHandshakeValid).pipe( + makeWsRpcLayer( + session, + clientOrigin, + previewAutomationBroker, + productHandshakeValid, + ).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 49f1b532a53a..7ac564bb41f3 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -220,6 +220,40 @@ describe("projectActivityPayload", () => { } }); + it("preserves failed stored tool outcomes for web and mobile clients", () => { + const activities = [ + makeActivity("failed-command", "command_execution", { + item: { + command: "vp test run", + exitCode: 1, + status: "failed", + }, + }), + makeActivity("failed-mcp", "mcp_tool_call", { + item: { + server: "simulator", + tool: "build", + arguments: {}, + status: "failed", + }, + }), + ]; + + for (const activity of activities) { + const projected = projectActivityPayload(activity); + expect(projected.payload).toMatchObject({ status: "failed" }); + + const [webEntry] = deriveWorkLogEntries([projected]); + expect(webEntry?.toolLifecycleStatus).toBe("failed"); + + const [mobileGroup] = buildThreadFeed(makeThread([projected])); + expect(mobileGroup).toMatchObject({ type: "activity-group" }); + if (mobileGroup?.type === "activity-group") { + expect(mobileGroup.activities[0]?.status).toBe("failure"); + } + } + }); + it("projects snapshot and event transports without mutating their sources", () => { const activity = fixtures[0]!; const thread = makeThread([activity]); @@ -387,31 +421,6 @@ describe("superseded tool.updated snapshot dedup", () => { expect(projectedIds([anonymous, completed])).toEqual([anonymous.id, completed.id]); }); - it("does not filter live activity-appended events", () => { - const update = makeToolLifecycleActivity("upd-live-event", "tool.updated"); - const event = { - sequence: 11, - eventId: EventId.make("event-tool-updated"), - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-projection"), - occurredAt: "2026-07-27T00:00:03.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.activity-appended", - payload: { - threadId: ThreadId.make("thread-projection"), - activity: update, - }, - } satisfies Extract; - - const projected = projectActivityEvent(event); - expect( - projected.type === "thread.activity-appended" ? projected.payload.activity.id : undefined, - ).toEqual(update.id); - }); - it("leaves the collapsed work log identical to the full history", () => { const activities = [ makeToolLifecycleActivity("upd-1", "tool.updated", { detail: "writing" }), @@ -532,29 +541,4 @@ describe("context-window snapshot dedup", () => { }); expect(projected.thread.activities).toEqual([projectActivityPayload(fixtures[4]!)]); }); - - it("does not filter live activity-appended events", () => { - const activity = makeContextWindowActivity("ctx-live", 4_000); - const event = { - sequence: 9, - eventId: EventId.make("event-ctx"), - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-projection"), - occurredAt: "2026-07-27T00:00:02.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.activity-appended", - payload: { - threadId: ThreadId.make("thread-projection"), - activity, - }, - } satisfies Extract; - - const projected = projectActivityEvent(event); - expect( - projected.type === "thread.activity-appended" ? projected.payload.activity : undefined, - ).toEqual(activity); - }); }); diff --git a/apps/web/package.json b/apps/web/package.json index 8cad680664eb..a5d6d19f54ee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "class-variance-authority": "^0.7.1", "culori": "^4.0.2", "effect": "catalog:", + "heic-to": "^1.5.2", "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", diff --git a/apps/web/src/appearanceContrast.test.ts b/apps/web/src/appearanceContrast.test.ts new file mode 100644 index 000000000000..3e6c1fad0448 --- /dev/null +++ b/apps/web/src/appearanceContrast.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { applyAppearanceContrast } from "./appearanceContrast"; + +function makeRoot() { + const setProperty = vi.fn(); + return { + root: { style: { setProperty } } as unknown as HTMLElement, + setProperty, + }; +} + +describe("applyAppearanceContrast", () => { + it("boosts semantic contrast above the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 135); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "8.75%"); + }); + + it("supports the maximum contrast boost", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 200); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "25%"); + }); + + it("softens semantic contrast below the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 70); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); + }); + + it("disables contrast mixing at the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 100); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); + }); +}); diff --git a/apps/web/src/appearanceContrast.ts b/apps/web/src/appearanceContrast.ts new file mode 100644 index 000000000000..a26dca0131e6 --- /dev/null +++ b/apps/web/src/appearanceContrast.ts @@ -0,0 +1,10 @@ +import type { AppearanceContrast } from "@t3tools/contracts/settings"; + +export function applyAppearanceContrast(root: HTMLElement, contrast: AppearanceContrast): void { + root.style.setProperty("--appearance-contrast-base", `${Math.min(contrast, 100)}%`); + root.style.setProperty("--appearance-contrast-boost", `${Math.max(contrast - 100, 0)}%`); + root.style.setProperty( + "--appearance-contrast-border-boost", + `${Math.max(contrast - 100, 0) / 4}%`, + ); +} diff --git a/apps/web/src/browser/annotationTheme.ts b/apps/web/src/browser/annotationTheme.ts index e12c667d23d7..cb3382449598 100644 --- a/apps/web/src/browser/annotationTheme.ts +++ b/apps/web/src/browser/annotationTheme.ts @@ -10,17 +10,17 @@ export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme { colorScheme: root.classList.contains("dark") ? "dark" : "light", radius: readVariable(styles, "--radius", "0.625rem"), background: readVariable(styles, "--background", "white"), - foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"), + foreground: readVariable(styles, "--contrast-foreground", "oklch(0.269 0 0)"), popover: readVariable(styles, "--popover", "white"), - popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"), + popoverForeground: readVariable(styles, "--contrast-popover-foreground", "oklch(0.269 0 0)"), primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"), primaryForeground: readVariable(styles, "--primary-foreground", "white"), muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"), - mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"), + mutedForeground: readVariable(styles, "--contrast-muted-foreground", "oklch(0.556 0 0)"), accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"), - accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"), - border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"), - input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"), + accentForeground: readVariable(styles, "--contrast-accent-foreground", "oklch(0.269 0 0)"), + border: readVariable(styles, "--contrast-border", "rgb(0 0 0 / 8%)"), + input: readVariable(styles, "--contrast-input", "rgb(0 0 0 / 10%)"), ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"), fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"), fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"), diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 9499ee5a6915..1edf37e9b84a 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,6 +1,29 @@ -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, + useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), + useProjects: () => [], +})); +vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => undefined, + matchesLinkedPullRequestUrl: () => false, + parseChangeRequestUrl: () => null, + useOpenChangeRequestLink: () => vi.fn(), +})); + +import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown"; describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -24,13 +47,115 @@ describe("orderedListGutterStyle", () => { it("accounts for a non-default start attribute", () => { // start=95 + 9 items => last marker is "103", three digits. expect(orderedListGutterStyle(9, 95)).toEqual({ "--list-gutter": "4ch" }); + expect(orderedListGutterStyle(5, "999995")).toEqual({ "--list-gutter": "7ch" }); }); it("scales further for four-digit markers", () => { expect(orderedListGutterStyle(1000, undefined)).toEqual({ "--list-gutter": "5ch" }); }); + it("uses the widest marker and includes a negative start's minus sign", () => { + expect(orderedListGutterStyle(1001, -1000)).toEqual({ "--list-gutter": "6ch" }); + expect(orderedListGutterStyle(3, -15)).toEqual({ "--list-gutter": "4ch" }); + expect(orderedListGutterStyle(3, -5)).toBeUndefined(); + }); + it("treats a missing/zero item count as a single item", () => { expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); + expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" }); + }); +}); + +describe("ChatMarkdown Windows file links", () => { + 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 a730b962da03..40bc6e5963a5 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -14,12 +14,17 @@ import { TriangleAlertIcon, WrapTextIcon, } from "lucide-react"; -import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; +import type { + 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 * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { @@ -73,16 +78,20 @@ import { } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { + extractMarkdownLinkHrefs, 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 { useRightPanelStore } from "../rightPanelStore"; -import { useActiveEnvironmentId } from "../state/entities"; +import { readThreadShell, useActiveEnvironmentId, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; @@ -90,15 +99,20 @@ 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 { useAssetUrl } from "../assets/assetUrls"; import { isLocalMarkdownImageSrc, normalizeLocalMarkdownImageSrc } from "../markdown-images"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { @@ -146,6 +160,7 @@ interface ChatMarkdownProps { const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; 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; @@ -180,22 +195,54 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb } /** - * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits two-digit - * decimal markers. Once a list's last item reaches three digits (item 100+), - * `list-style-position: outside` paints the marker wider than that gutter and - * the leading digit gets clipped by the item's own overflow. Rather than - * widening the gutter for every list, only lists whose last marker is 3+ - * digits get a wider `--list-gutter`, sized to that marker's digit count. + * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits markers up to + * two characters wide. Once a marker reaches three characters (item 100+), + * `list-style-position: outside` paints it wider than that gutter and clips + * the leading character against the item's own overflow. Rather than widening + * the gutter for every list, only lists whose widest marker is 3+ characters + * get a wider `--list-gutter`. The width includes a negative marker's minus + * sign. */ export function orderedListGutterStyle( itemCount: number, - start: number | undefined, + start: unknown, ): { "--list-gutter": string } | undefined { - const firstNumber = typeof start === "number" && Number.isFinite(start) ? start : 1; + const parsedStart = Number.parseInt(String(start ?? 1), 10); + const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); - const digits = String(Math.abs(lastNumber)).length; - if (digits <= 2) return undefined; - return { "--list-gutter": `${digits + 1}ch` }; + const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); + if (markerWidth <= 2) return undefined; + return { "--list-gutter": `${markerWidth + 1}ch` }; +} + +type MarkdownHtmlAstNode = { + type?: string; + tagName?: string; + properties?: Record; + children?: MarkdownHtmlAstNode[]; +}; + +/** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */ +function rehypeNormalizeWindowsImageSrc() { + return (tree: MarkdownHtmlAstNode) => { + const visit = (node: MarkdownHtmlAstNode) => { + const src = node.properties?.src; + if ( + node.type === "element" && + node.tagName === "img" && + typeof src === "string" && + WINDOWS_DRIVE_PATH_REGEX.test(src) + ) { + node.properties = { + ...node.properties, + src: `file:///${src.replaceAll("\\", "/")}`, + }; + } + node.children?.forEach(visit); + }; + + visit(tree); + }; } const CHAT_MARKDOWN_SANITIZE_SCHEMA = { @@ -209,6 +256,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file"], + src: [...(defaultSchema.protocols?.src ?? []), "file"], }, } satisfies Parameters[0]; @@ -217,7 +265,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, - remarkTagInlineCode, + remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -226,11 +274,12 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, - remarkTagInlineCode, + remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, + rehypeNormalizeWindowsImageSrc, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; @@ -311,6 +360,7 @@ function extractPreCodeMeta(node: unknown): string | undefined { type MarkdownAstNode = { type?: string; meta?: unknown; + url?: string; data?: { hProperties?: Record; }; @@ -337,15 +387,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, @@ -869,7 +924,6 @@ interface MarkdownFileLinkProps { className?: string | undefined; } -const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; @@ -882,14 +936,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); } @@ -948,19 +1000,12 @@ function extractInlineCodeSpans(text: string): string[] { return spans; } -function extractMarkdownLinkHrefs(text: string): string[] { - const hrefs: string[] = []; - for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { - const href = match[1]?.trim(); - if (!href) continue; - hrefs.push(href); - } - return hrefs; -} - 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"; @@ -994,51 +1039,58 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: ); }); -const MARKDOWN_IMAGE_CLASS_NAME = - "my-2 max-h-[28rem] max-w-full rounded-md border border-border/60 object-contain bg-muted/20"; +const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = + "h-auto w-auto max-h-[30rem] max-w-[min(100%,30rem)] object-contain"; -const MarkdownWorkspaceImage = memo(function MarkdownWorkspaceImage({ - src, - alt, - threadRef, - className, - ...props -}: { - src: string; - alt?: string | undefined; - threadRef: ScopedThreadRef; - className?: string | undefined; -} & Omit, "src" | "alt" | "className">) { - const localPath = normalizeLocalMarkdownImageSrc(src); - const assetUrl = useAssetUrl(threadRef.environmentId, { +// block! outranks the unlayered `.chat-markdown img { display: inline-block }` +// rule, keeping workspace images on the same block layout as their placeholder. +const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( + CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, + "my-1 block! rounded-lg border border-border/40", +); + +function ChatMarkdownImageFallback(props: { readonly alt: string }) { + return ( + + + {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; + readonly alt: string; +}) { + const assetUrl = useAssetUrlState(props.threadRef.environmentId, { _tag: "workspace-file", - threadId: threadRef.threadId, - path: localPath, + threadId: props.threadRef.threadId, + path: props.path, }); - const [failed, setFailed] = useState(false); + const [failedUrl, setFailedUrl] = useState(null); - if (failed || assetUrl === null) { + if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { + return ; + } + if (assetUrl._tag !== "Success") { return ( - 🖼 - - {alt?.trim() || localPath.split(/[/\\]/).at(-1) || "Image"} - - + role="status" + aria-label="Loading image" + className="my-1 block aspect-video w-full max-w-[30rem] rounded-lg bg-muted/60" + /> ); } - return ( {alt setFailed(true)} + draggable={false} + className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME} + onError={() => setFailedUrl(assetUrl.url)} /> ); }); @@ -1400,7 +1452,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - if (onOpenInBrowser) { + if (shouldOpenMarkdownFileLinkInEditor(event)) { + handleOpenInEditor(); + return; + } + if (onOpenInBrowser && shouldOpenMarkdownFileLinkInBrowserByDefault(iconPath)) { handleOpenInBrowser(); return; } @@ -1416,8 +1472,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ side="top" className="max-w-[min(40rem,calc(100vw-2rem))] font-mono text-[11px] leading-tight" > -
- {displayPath} + {/* The full path: the chip already shows the shortened form, and a link + to the workspace root collapses to a bare label that repeats it. */} +
+ {targetPath}
@@ -1467,9 +1525,16 @@ function ChatMarkdown({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); const environmentId = useActiveEnvironmentId(); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const threadServerConfig = useAtomValue( + serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), + ); + const projects = useProjects(); const openInPreferredEditor = useOpenInPreferredEditor( environmentId, serverConfig?.availableEditors ?? [], @@ -1523,6 +1588,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) { @@ -1609,7 +1722,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); @@ -1647,30 +1762,6 @@ function ChatMarkdown({ }; return { - img({ node: _node, src, alt, className, title: _title, ...props }) { - const srcValue = typeof src === "string" ? src : undefined; - if (srcValue && isLocalMarkdownImageSrc(srcValue) && threadRef) { - return ( - - ); - } - if (!srcValue) return null; - return ( - {alt - ); - }, p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, @@ -1744,7 +1835,10 @@ function ChatMarkdown({ }, a({ node, href, children, title: _title, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; - const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; + const fileLinkMeta = normalizedHref + ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? + resolveMarkdownFileLinkMeta(normalizedHref, cwd)) + : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; @@ -1774,9 +1868,20 @@ function ChatMarkdown({ event.stopPropagation(); const api = readLocalApi(); if (!api) return; + const pullRequest = resolveThreadPullRequest(href); + const currentPullRequest = + threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; + const threadLinkAction = + currentPullRequest != null && + matchesLinkedPullRequestUrl(currentPullRequest, href) + ? "unlink-from-thread" + : pullRequest === null + ? undefined + : "link-to-thread"; void showExternalLinkContextMenu({ href, canOpenInPreview, + threadLinkAction, position: { x: event.clientX, y: event.clientY }, showContextMenu: (items, position) => api.contextMenu.show(items, position), openInPreview: async (target) => { @@ -1790,8 +1895,25 @@ 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.", + }), + ); + } }, }); }} @@ -1843,6 +1965,43 @@ function ChatMarkdown({
); }, + img({ node: _node, title: _title, src, alt, ...props }) { + const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : ""; + const altText = alt ?? ""; + const imageSource = classifyMarkdownImageSource(srcString, cwd); + if (imageSource._tag === "Direct") { + return ( + {altText} + ); + } + if (imageSource._tag === "WorkspaceFile" && threadRef) { + return ( + + ); + } + // Codex ACP uses `attachment:` / generated_images host paths that the + // shared classifier treats as blocked URI schemes. + if (isLocalMarkdownImageSrc(srcString) && threadRef) { + return ( + + ); + } + return ; + }, table({ node: _node, ...props }) { return ; }, @@ -1888,12 +2047,15 @@ function ChatMarkdown({ onTaskListChange, openFileInPanel, openInPreferredEditor, + openChangeRequestLink, openExternalLinkInPreview, openMarkdownFileInPreview, + resolveThreadPullRequest, resolvedTheme, skills, text, threadRef, + updateThreadPullRequestLink, ]); /* eslint-enable react/no-unstable-nested-components */ diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx new file mode 100644 index 000000000000..e7b042a62c95 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -0,0 +1,142 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + resources: [] as Array, + assetState: "success" as "success" | "loading", +})); + +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" }; + }, +})); +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, + useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), + useProjects: () => [], +})); +vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => undefined, + matchesLinkedPullRequestUrl: () => false, + parseChangeRequestUrl: () => null, + useOpenChangeRequestLink: () => vi.fn(), +})); + +import ChatMarkdown from "./ChatMarkdown"; + +const threadRef = { + environmentId: EnvironmentId.make("env-windows"), + threadId: ThreadId.make("thread-windows"), +}; + +function render(markdown: string): string { + return renderToStaticMarkup( + , + ); +} + +function renderWithoutThread(markdown: string): string { + return renderToStaticMarkup(); +} + +describe("ChatMarkdown workspace images", () => { + beforeEach(() => { + testState.resources = []; + testState.assetState = "success"; + }); + + 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( + [ + "![relative](.t3/workspace-image.svg)", + `![absolute](${imagePath})`, + `![file URL](file:///${imagePath})`, + "![UNC file URL](file://server/share/workspace-image.svg)", + ].join("\n\n"), + ); + + expect(testState.resources).toEqual([ + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", + }, + { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "\\\\server\\share\\workspace-image.svg", + }, + ]); + expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4); + expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4); + expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4); + expect(html).not.toContain("Image unavailable"); + }); + + it("normalizes a drive-absolute src in raw image HTML", () => { + const html = render(String.raw`raw`); + + expect(testState.resources).toEqual([ + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "D:/screens/workspace-image.svg", + }, + ]); + expect(html).toContain("https://signed.test/workspace-image.svg"); + }); + + it("uses a static placeholder while a signed asset URL loads", () => { + testState.assetState = "loading"; + + const html = render("![loading](.t3/workspace-image.svg)"); + + expect(html).toContain('aria-label="Loading image"'); + expect(html).not.toContain("animate-pulse"); + }); + + it("never passes a workspace source to a raw image when thread context is unavailable", () => { + const html = renderWithoutThread( + "![file URL](file:///C:/Users/shawn/project/workspace-image.svg)", + ); + + expect(testState.resources).toEqual([]); + expect(html).toContain("Image unavailable"); + expect(html).not.toContain("file://"); + }); + + it("blocks unsupported image schemes instead of passing them to a raw image", () => { + const html = render("![unsupported](content://media/image/1)"); + + expect(testState.resources).toEqual([]); + expect(html).toContain("Image unavailable"); + expect(html).not.toContain("content://"); + }); + + it("keeps remote images directly loadable", () => { + const html = render("![remote](https://example.com/image.png)"); + + expect(testState.resources).toEqual([]); + expect(html).toContain('src="https://example.com/image.png"'); + expect(html).toContain("max-w-[min(100%,30rem)]"); + expect(html).toContain("max-h-[30rem]"); + expect(html).not.toContain("Image unavailable"); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 1f858b387a83..1d0e8b0eedc5 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -28,13 +28,18 @@ import { resolvedSteeredMessageIds, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveBackgroundDraftWorkspaceOptions, + resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + resolveDraftHeroState, + resolveServerThreadError, shouldRenderServerThreadRoute, shouldTreatServerThreadAsActive, - resolveServerThreadError, scheduleEnvironmentReconnectWarning, startNewThreadForProject, + shouldDockDraftHeroForSubmission, + shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -50,6 +55,148 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("draft hero submission transition", () => { + it("does not dock the composer before a background submission", () => { + expect( + shouldDockDraftHeroForSubmission({ + isDraftHeroState: true, + activeThreadKey: "environment-local:thread-1", + submissionIntent: "background", + }), + ).toBe(false); + }); + + it("keeps the composer in the hero layout until navigation after server promotion", () => { + expect( + resolveDraftHeroState({ + isLocalDraftThread: false, + hasTimelineEntries: true, + isWorking: true, + draftHeroDockRequested: false, + backgroundSubmissionPending: true, + }), + ).toBe(true); + }); + + it("does not auto-navigate a background submission after server promotion", () => { + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef: { environmentId, threadId }, + serverThreadStarted: true, + backgroundSubmissionPending: true, + }), + ).toBeNull(); + }); +}); + +describe("shouldReleaseTimelineAnchorForToolActivity", () => { + const activeTurnId = TurnId.make("active-turn"); + const anchorMessageId = MessageId.make("anchored-message"); + const activeToolEntry = { + id: "tool-entry", + kind: "work" as const, + createdAt: now, + entry: { + id: "active-tool", + createdAt: now, + turnId: activeTurnId, + label: "Run command", + tone: "tool" as const, + command: "git status", + }, + }; + + it("releases the send anchor for tool activity in the active turn", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }), + ).toBe(true); + }); + + it("keeps the anchor while the user reads history", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: false, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }), + ).toBe(false); + }); + + it("ignores tool activity from earlier turns", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [ + { + ...activeToolEntry, + entry: { + ...activeToolEntry.entry, + turnId: TurnId.make("previous-turn"), + }, + }, + ], + }), + ).toBe(false); + }); + + it("ignores thinking and error rows without tool activity", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [ + { + ...activeToolEntry, + entry: { + id: "thinking-entry", + createdAt: now, + turnId: activeTurnId, + label: "Thinking", + tone: "thinking", + }, + }, + { + ...activeToolEntry, + id: "error-entry", + entry: { + id: "error-entry", + createdAt: now, + turnId: activeTurnId, + label: "Provider error", + tone: "error", + }, + }, + ], + }), + ).toBe(false); + }); + + it("does nothing without an anchor or running turn", () => { + const input = { + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }; + + expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( + false, + ); + expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( + false, + ); + }); +}); + describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); @@ -445,6 +592,23 @@ describe("resolveSendEnvMode", () => { }); }); +describe("resolveBackgroundDraftWorkspaceOptions", () => { + it("keeps New worktree selected without reusing the launched worktree", () => { + expect( + resolveBackgroundDraftWorkspaceOptions({ + envMode: "worktree", + branch: "main", + startFromOrigin: true, + }), + ).toEqual({ + envMode: "worktree", + branch: "main", + worktreePath: null, + startFromOrigin: true, + }); + }); +}); + describe("branchMismatchKey", () => { it("builds a key from thread id and both branches", () => { expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( @@ -658,6 +822,30 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(false); }); + it("keeps a follow-up active while its provider session is starting", () => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestTurn: completedTurn, session: readySession }), + ); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "connecting", + latestTurn: completedTurn, + latestUserMessageId: MessageId.make("message-followup"), + projectedMessageIds: new Set(), + session: { + ...readySession, + status: "starting", + updatedAt: "2026-03-29T00:01:00.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + it("acknowledges a settled newer turn", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestTurn: completedTurn, session: readySession }), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0ba523d56f3e..b1a2168b7e19 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -22,6 +22,8 @@ import { type TerminalContextDraft, } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; +import type { ComposerSubmissionIntent } from "../composer-logic"; +import type { TimelineEntry } from "../session-logic"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -30,6 +32,72 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function shouldDockDraftHeroForSubmission(input: { + isDraftHeroState: boolean; + activeThreadKey: string | null; + submissionIntent: ComposerSubmissionIntent; +}): boolean { + return ( + input.submissionIntent === "foreground" && + input.isDraftHeroState && + input.activeThreadKey !== null + ); +} + +export function shouldReleaseTimelineAnchorForToolActivity(input: { + anchorMessageId: MessageId | null; + liveFollowEnabled: boolean; + runningTurnId: TurnId | null; + timelineEntries: ReadonlyArray; +}): boolean { + if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) { + return false; + } + + return input.timelineEntries.some((timelineEntry) => { + if (timelineEntry.kind !== "work" || timelineEntry.entry.turnId !== input.runningTurnId) { + return false; + } + + const entry = timelineEntry.entry; + return ( + entry.tone === "tool" || + entry.itemType !== undefined || + entry.requestKind !== undefined || + (entry.command?.trim().length ?? 0) > 0 + ); + }); +} + +export function resolveDraftHeroState(input: { + isLocalDraftThread: boolean; + hasTimelineEntries: boolean; + isWorking: boolean; + draftHeroDockRequested: boolean; + backgroundSubmissionPending: boolean; +}): boolean { + if (input.backgroundSubmissionPending) { + return true; + } + return ( + input.isLocalDraftThread && + !input.hasTimelineEntries && + !input.isWorking && + !input.draftHeroDockRequested + ); +} + +export function resolveDraftPromotionNavigationTarget(input: { + serverThreadRef: ScopedThreadRef | null; + serverThreadStarted: boolean; + backgroundSubmissionPending: boolean; +}): ScopedThreadRef | null { + if (input.backgroundSubmissionPending) { + return null; + } + return input.serverThreadStarted ? input.serverThreadRef : null; +} + export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); return () => globalThis.clearTimeout(timeoutId); @@ -347,6 +415,24 @@ export function resolveSendEnvMode(input: { return input.isGitRepo ? input.requestedEnvMode : "local"; } +export function resolveBackgroundDraftWorkspaceOptions(input: { + envMode: DraftThreadEnvMode; + branch: string | null; + startFromOrigin: boolean; +}): { + envMode: DraftThreadEnvMode; + branch: string | null; + worktreePath: null; + startFromOrigin: boolean; +} { + return { + envMode: input.envMode, + branch: input.branch, + worktreePath: null, + startFromOrigin: input.envMode === "worktree" && input.startFromOrigin, + }; +} + export function cloneComposerImageForRetry( image: ComposerImageAttachment, ): ComposerImageAttachment { @@ -588,6 +674,7 @@ export interface LocalDispatchSnapshot { * that other clients' activity could satisfy. */ expectedMessageId: ChatMessage["id"] | null; + submissionIntent: ComposerSubmissionIntent; latestUserMessageId: ChatMessage["id"] | null; latestTurnTurnId: TurnId | null; latestTurnRequestedAt: string | null; @@ -599,7 +686,11 @@ export interface LocalDispatchSnapshot { export function createLocalDispatchSnapshot( activeThread: Thread | undefined, - options?: { preparingWorktree?: boolean; messageId?: ChatMessage["id"] }, + options?: { + preparingWorktree?: boolean; + messageId?: ChatMessage["id"]; + submissionIntent?: ComposerSubmissionIntent; + }, ): LocalDispatchSnapshot { const latestTurn = activeThread?.latestTurn ?? null; const session = activeThread?.session ?? null; @@ -608,6 +699,7 @@ export function createLocalDispatchSnapshot( startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), expectedMessageId: options?.messageId ?? null, + submissionIntent: options?.submissionIntent ?? "foreground", latestUserMessageId: latestUserMessage?.id ?? null, latestTurnTurnId: latestTurn?.turnId ?? null, latestTurnRequestedAt: latestTurn?.requestedAt ?? null, @@ -635,6 +727,9 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } + if (input.phase === "connecting") { + return false; + } const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 58abd7baa16e..f2b811de03cb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,12 +26,19 @@ import { connectionStatusTitle, 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 { + codexFeedbackMessage, + parseCodexFeedbackCommand, + submitCodexFeedback, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; import { parseScopedThreadKey, scopedThreadKey, @@ -76,6 +83,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 { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; import { @@ -87,6 +95,7 @@ import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, + type ComposerSubmissionIntent, parseStandaloneComposerSlashCommand, } from "../composer-logic"; import { @@ -130,6 +139,7 @@ import { } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; @@ -181,6 +191,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + Minimize2Icon, PaperclipIcon, WifiOffIcon, } from "lucide-react"; @@ -205,13 +216,18 @@ import { 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, useEnvironmentSettings, } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; +import { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -226,10 +242,15 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; -import { buildDraftThreadRouteParams } from "../threadRoutes"; +import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { + beginBackgroundDraftSubmissionByRef, + clearBackgroundDraftSubmissionByRef, + composerDraftHasUserContent, type ComposerImageAttachment, type DraftThreadEnvMode, + finalizePromotedDraftThreadByRef, + markPromotedDraftThreadByRef, useComposerDraftStore, type DraftId, } from "../composerDraftStore"; @@ -309,9 +330,17 @@ import { import { resolveDisplayedThreadPr, threadChangeRequestSnapshotsAtom, + useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { QueuedMessageChips, type DisplayQueuedMessage } from "./chat/QueuedMessageChips"; +import { + hasAvailableClaudeCompactionProvider, + hasDismissedResumeCompaction, + shouldOfferResumeCompaction, +} from "./chat/ContextWindowMeter.logic"; +import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; +import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, DRAFT_HERO_TRANSITION_DURATION_MS, @@ -335,6 +364,8 @@ import { scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, + shouldDockDraftHeroForSubmission, + shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -345,6 +376,8 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveBackgroundDraftWorkspaceOptions, + resolveDraftHeroState, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -357,6 +390,12 @@ import { } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; +import { + awaitAttachmentUploads, + getUploadedAttachments, + releaseAttachmentUploads, + startAttachmentUpload, +} from "../lib/attachmentUploadQueue"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; @@ -646,14 +685,22 @@ function useLocalDispatchState(input: { ); const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; const beginLocalDispatch = useCallback( - (options?: { preparingWorktree?: boolean; messageId?: MessageId }) => { + (options?: { + preparingWorktree?: boolean; + messageId?: MessageId; + submissionIntent?: ComposerSubmissionIntent; + }) => { const preparingWorktree = Boolean(options?.preparingWorktree); setLocalDispatch((current) => { const active = serverAcknowledgedLocalDispatch ? null : current; if (active) { - return active.preparingWorktree === preparingWorktree + const submissionIntent = options?.submissionIntent ?? active.submissionIntent; + const expectedMessageId = options?.messageId ?? active.expectedMessageId; + return active.preparingWorktree === preparingWorktree && + active.submissionIntent === submissionIntent && + active.expectedMessageId === expectedMessageId ? active - : { ...active, preparingWorktree }; + : { ...active, preparingWorktree, submissionIntent, expectedMessageId }; } return createLocalDispatchSnapshot(input.activeThread, options); }); @@ -668,6 +715,7 @@ function useLocalDispatchState(input: { latestUserMessageAt: latestUserMessage?.createdAt ?? null, isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, isSendBusy: activeLocalDispatch !== null, + backgroundSubmissionPending: localDispatch?.submissionIntent === "background", }; } @@ -1255,6 +1303,20 @@ function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +/** + * Drops the send-time anchored end space. That space is what holds a sent + * message near the top while its turn streams, and it keeps LegendList's + * maintainScrollAtEnd switched off for as long as it is installed — ChatView + * drives the streaming scrolls itself, but only in "anchoring-new-turn" mode. + * So every return to the live edge has to release the anchor too, otherwise the + * timeline settles into "following-end" with nothing following anything. + */ +function releaseChatTimelineAnchor( + current: T, +): T { + return current.messageId === null ? current : { ...current, messageId: null }; +} + function ChatViewContent(props: ChatViewProps) { const { environmentId, @@ -1267,6 +1329,8 @@ function ChatViewContent(props: ChatViewProps) { } = props; const threadDetailLoading = threadSyncPhase === "loading"; const draftId = routeKind === "draft" ? props.draftId : null; + const handleNewThread = useNewThreadHandler(); + const { settleThread } = useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -1299,6 +1363,9 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { + reportFailure: false, + }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -1322,7 +1389,6 @@ function ChatViewContent(props: ChatViewProps) { const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); - const handleNewThread = useNewThreadHandler(); const environmentById = useMemo( () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], @@ -1388,6 +1454,9 @@ function ChatViewContent(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); + const composerHasUnsentContent = useComposerDraftStore((store) => + composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), + ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const setComposerDraftTerminalContexts = useComposerDraftStore( @@ -1425,6 +1494,16 @@ function ChatViewContent(props: ChatViewProps) { const [hasUnreadTimelineActivity, setHasUnreadTimelineActivity] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< + Record> + >({}); + const feedbackSubmissions = useMemo( + () => feedbackSubmissionsByThreadKey[routeThreadKey] ?? [], + [feedbackSubmissionsByThreadKey, routeThreadKey], + ); + const feedbackUploading = feedbackSubmissions.some( + (submission) => submission.status === "uploading", + ); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; // Optimistic sends the server will hold in the steering queue. They are the @@ -1505,6 +1584,7 @@ function ChatViewContent(props: ChatViewProps) { const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const queuedTurnDrainInFlightRef = useRef(false); + const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); useLayoutEffect(() => { @@ -1812,6 +1892,9 @@ function ChatViewContent(props: ChatViewProps) { return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; + const activeRunningTurnId = + (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ?? + (activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null); // Reading a finished thread clears the sidebar's Done badge. The visit is // stamped at the turn's completion time — not now/updatedAt — so it clears // exactly the completion the user is looking at: a wake or completion that @@ -2207,6 +2290,10 @@ function ChatViewContent(props: ChatViewProps) { : (primaryEnvironment?.serverConfig ?? null); const pullRequestsCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; + const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; + const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; + const supportsAttachmentUploads = + attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2402,6 +2489,10 @@ function ChatViewContent(props: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + const activeContextWindow = useMemo( + () => deriveLatestContextWindowSnapshot(threadActivities), + [threadActivities], + ); const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the @@ -2499,6 +2590,7 @@ function ChatViewContent(props: ChatViewProps) { latestUserMessageAt, isPreparingWorktree, isSendBusy, + backgroundSubmissionPending, } = useLocalDispatchState({ activeThread, activeLatestTurn, @@ -2758,12 +2850,20 @@ function ChatViewContent(props: ChatViewProps) { return changed ? { ...message, attachments } : message; }); - if (optimisticUserMessages.length === 0) { + const localMessages = [ + ...optimisticUserMessages, + ...feedbackSubmissions.flatMap((submission) => + submission.status === "interrupted" + ? [] + : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], + ), + ]; + if (localMessages.length === 0) { return serverMessagesWithPreviewHandoff; } const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); // Queue-bound sends render as chips above the composer, never as rows. - const pendingMessages = optimisticUserMessages.filter( + const pendingMessages = localMessages.filter( (message) => !serverIds.has(message.id) && !optimisticQueuedMessageIds.has(message.id), ); if (pendingMessages.length === 0) { @@ -2773,6 +2873,7 @@ function ChatViewContent(props: ChatViewProps) { }, [ attachmentPreviewHandoffByMessageId, displayServerMessages, + feedbackSubmissions, optimisticQueuedMessageIds, optimisticUserMessages, ]); @@ -2831,8 +2932,13 @@ function ChatViewContent(props: ChatViewProps) { const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; - const isDraftHeroState = - isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; + const isDraftHeroState = resolveDraftHeroState({ + isLocalDraftThread, + hasTimelineEntries: timelineEntries.length > 0, + isWorking, + draftHeroDockRequested, + backgroundSubmissionPending, + }); const [ attachDraftHeroTransitionGroupRef, attachDraftHeroComposerAnchorRef, @@ -2911,6 +3017,29 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; + const compactionProviderAvailable = useMemo( + () => + hasAvailableClaudeCompactionProvider({ + providers: applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + ), + instanceId: activeProviderInstanceId, + lockedInstanceId: lockedProvider + ? (activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + null) + : null, + }), + [ + activeProviderInstanceId, + activeThread?.modelSelection.instanceId, + activeThread?.session?.providerInstanceId, + lockedProvider, + providerStatuses, + settings, + ], + ); const activeProviderStatus = useMemo(() => { if (activeProviderInstanceId) { return ( @@ -2920,6 +3049,25 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = + useLocalStorage( + `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, + false, + Schema.Boolean, + ); + const nativeResumeCompactionDismissed = useMemo( + () => hasDismissedResumeCompaction(threadActivities), + [threadActivities], + ); + useEffect(() => { + if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) { + setResumeCompactionPermanentlyDismissed(true); + } + }, [ + nativeResumeCompactionDismissed, + resumeCompactionPermanentlyDismissed, + setResumeCompactionPermanentlyDismissed, + ]); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -3553,25 +3701,50 @@ function ChatViewContent(props: ChatViewProps) { ); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. - const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; + const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; const openThreadPullRequest = useCallback( (number: number, repository: string | null = threadRepository) => { - const selectedRepository = repository ?? threadRepository; + if (!supportsPullRequests || !activeThreadRef) { + return; + } + const projectId = linkedThreadPullRequest?.projectId ?? activeProject?.id; + const selectedRepository = + repository ?? linkedThreadPullRequest?.repository ?? activeProjectRepository; + if (projectId === undefined || selectedRepository === null) return; + useRightPanelStore.getState().openPullRequest(activeThreadRef, { + projectId, + repository: selectedRepository, + number, + }); + }, + [ + activeProject, + activeProjectRepository, + activeThreadRef, + linkedThreadPullRequest, + supportsPullRequests, + threadRepository, + ], + ); + const openProjectPullRequest = useCallback( + (number: number) => { if ( !supportsPullRequests || !activeThreadRef || !activeProject || - selectedRepository === null + activeProjectRepository === null ) { return; } useRightPanelStore.getState().openPullRequest(activeThreadRef, { projectId: activeProject.id, - repository: selectedRepository, + repository: activeProjectRepository, number, }); }, - [activeProject, activeThreadRef, supportsPullRequests, threadRepository], + [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests], ); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; @@ -4053,17 +4226,39 @@ function ChatViewContent(props: ChatViewProps) { liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); setHasUnreadTimelineActivity(false); - setTimelineAnchor((current) => - current.messageId === null ? current : { ...current, messageId: null }, - ); + setTimelineAnchor(releaseChatTimelineAnchor); requestAnimationFrame(() => { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); + useLayoutEffect(() => { + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } + + if ( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId: timelineAnchorMessageId, + liveFollowEnabled: timelineLiveFollowEnabled, + runningTurnId: activeRunningTurnId, + timelineEntries, + }) + ) { + scrollToEnd(); + } + }, [ + activeRunningTurnId, + scrollToEnd, + timelineAnchorMessageId, + timelineEntries, + timelineLiveFollowEnabled, + ]); useEffect(() => { let removeListeners: (() => void) | null = null; let frame: number | null = null; @@ -4234,6 +4429,11 @@ function ChatViewContent(props: ChatViewProps) { timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); + // Reachable only once manual navigation has already broken follow, so + // the anchored turn framing is over: the user scrolled back to the live + // edge and expects the stream to stick to it again, exactly like the + // scroll-to-bottom pill. + setTimelineAnchor(releaseChatTimelineAnchor); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); setHasUnreadTimelineActivity(false); @@ -4551,11 +4751,17 @@ function ChatViewContent(props: ChatViewProps) { : null; const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); + const linkedPullRequestStatus = useLinkedThreadPullRequest( + activeThreadRef?.environmentId ?? null, + linkedThreadPullRequest, + ); const activeThreadPr = resolveDisplayedThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, + linkedPullRequest: linkedThreadPullRequest, + linkedPullRequestStatus, }); // The right panel offers the thread's own change request, so it can only offer it once the // branch has one; until then the picker says so rather than opening an empty panel. @@ -4731,18 +4937,6 @@ function ChatViewContent(props: ChatViewProps) { // Dismissal lives in a module-level set (survives remounts); this tick just // forces a re-render so the banner leaves immediately. const [, setBranchMismatchDismissTick] = useState(0); - const composerHasDraftContent = useComposerDraftStore((store) => { - const draft = store.getComposerDraft(composerDraftTarget); - return Boolean( - draft && - (draft.prompt.trim().length > 0 || - draft.images.length > 0 || - draft.terminalContexts.length > 0 || - draft.elementContexts.length > 0 || - draft.previewAnnotations.length > 0 || - draft.reviewComments.length > 0), - ); - }); const activeBranchMismatchKey = branchMismatchKey( activeThread?.id ?? null, localCheckoutBranchMismatch, @@ -4750,7 +4944,7 @@ function ChatViewContent(props: ChatViewProps) { const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ hasMismatch: localCheckoutBranchMismatch !== null, isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), - composerHasContent: composerHasDraftContent, + composerHasContent: composerHasUnsentContent, wasShownForCurrentMismatch: revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, }); @@ -4974,6 +5168,107 @@ function ChatViewContent(props: ChatViewProps) { isUnsnoozing, isUnsettling, ]); + // Session-scoped dismissals, one key per (thread, snapshot). A set rather + // than a single slot so dismissing the banner on one thread does not + // resurface it on another thread dismissed earlier. + const [dismissedResumeCompactionKeys, setDismissedResumeCompactionKeys] = useState< + ReadonlySet + >(new Set()); + const resumeCompactionKey = + activeThread && activeContextWindow + ? `${activeThread.id}:${activeContextWindow.updatedAt}` + : null; + const compactDisabled = + !activeThread || + !activeProject || + !isServerThread || + selectedProvider !== "claudeAgent" || + !compactionProviderAvailable || + isWorking || + threadDetailLoading || + isPreparingWorktree || + activeEnvironmentUnavailable || + feedbackUploading || + pendingApprovals.length > 0 || + pendingUserInputs.length > 0 || + showPlanFollowUpPrompt || + composerHasUnsentContent; + const compactDisabledReason = compactDisabled + ? composerHasUnsentContent + ? "Send or clear your draft before compacting" + : !activeProject + ? "Choose a project before compacting" + : !compactionProviderAvailable + ? "Enable a Claude provider before compacting" + : "Compacting is unavailable right now" + : null; + const resumeCompactionBannerItem = useMemo(() => { + if ( + !activeThread || + !activeContextWindow || + resumeCompactionKey === null || + dismissedResumeCompactionKeys.has(resumeCompactionKey) || + resumeCompactionPermanentlyDismissed || + nativeResumeCompactionDismissed || + pendingUserInputs.length > 0 || + phase === "running" || + !shouldOfferResumeCompaction({ + provider: selectedProvider, + usedTokens: activeContextWindow.usedTokens, + updatedAt: activeContextWindow.updatedAt, + now: `${nowMinute}:00.000Z`, + }) + ) { + return null; + } + + const dismiss = () => + setDismissedResumeCompactionKeys((keys) => new Set(keys).add(resumeCompactionKey)); + const compactAction = ( + + ); + return { + id: `resume-compaction:${resumeCompactionKey}`, + variant: "info", + icon: , + title: "Resume with less context", + description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older session`, + actions: compactDisabledReason ? ( + + {compactAction}} /> + {compactDisabledReason} + + ) : ( + compactAction + ), + dismissLabel: "Keep full history", + onDismiss: dismiss, + }; + }, [ + activeContextWindow, + activeThread, + compactDisabled, + compactDisabledReason, + composerRef, + dismissedResumeCompactionKeys, + nativeResumeCompactionDismissed, + nowMinute, + pendingUserInputs.length, + phase, + resumeCompactionKey, + resumeCompactionPermanentlyDismissed, + selectedProvider, + ]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); @@ -4988,6 +5283,8 @@ function ChatViewContent(props: ChatViewProps) { const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; + const resumeCompactionItems = + resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { @@ -4995,6 +5292,7 @@ function ChatViewContent(props: ChatViewProps) { ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, + ...resumeCompactionItems, ...wokeThreadItems, ...parkedThreadItems, ]; @@ -5003,6 +5301,7 @@ function ChatViewContent(props: ChatViewProps) { ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, + ...resumeCompactionItems, ...wokeThreadItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, @@ -5052,6 +5351,7 @@ function ChatViewContent(props: ChatViewProps) { isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, + resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, wokeThreadBannerItem, @@ -5191,6 +5491,29 @@ function ChatViewContent(props: ChatViewProps) { }); if (!command) return; + if (command === "thread.settle") { + event.preventDefault(); + event.stopPropagation(); + if (!isServerThread || !activeThreadRef || !supportsSettlement) return; + if (activeThreadSettled) { + void handleUnsettleActiveThread(); + return; + } + + void settleThread(activeThreadRef).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + return; + } + if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); @@ -5294,6 +5617,8 @@ function ChatViewContent(props: ChatViewProps) { activeProject, activeRightPanelSurface, addTerminalSurface, + activeThreadRef, + activeThreadSettled, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, @@ -5305,7 +5630,11 @@ function ChatViewContent(props: ChatViewProps) { splitTerminal, splitPanelTerminal, keybindings, + handleUnsettleActiveThread, + isServerThread, onToggleDiff, + settleThread, + supportsSettlement, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, @@ -5374,6 +5703,7 @@ function ChatViewContent(props: ChatViewProps) { const onSend = async ( e?: { preventDefault: () => void }, + submissionIntent: ComposerSubmissionIntent = "foreground", directAnnotation?: { annotation: PreviewAnnotationPayload; image: ComposerImageAttachment | null; @@ -5396,7 +5726,8 @@ function ChatViewContent(props: ChatViewProps) { isConnecting || activeEnvironmentUnavailable || threadDetailLoading || - sendInFlightRef.current + sendInFlightRef.current || + feedbackUploadsInFlightRef.current.has(routeThreadKey) ) { notifyDirectAnnotationAttached(); return; @@ -5471,6 +5802,101 @@ function ChatViewContent(props: ChatViewProps) { composerPreviewAnnotations.length + composerReviewComments.length, }); + const feedbackCommand = + ctxSelectedProvider === "codex" && + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0 + ? parseCodexFeedbackCommand(trimmed) + : null; + if (feedbackCommand) { + if (!isServerThread || activeThread.session === null) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Start a Codex thread first", + description: "Send a message before you submit feedback.", + }), + ); + return; + } + feedbackUploadsInFlightRef.current.add(routeThreadKey); + const result = await submitCodexFeedback({ + submission: { + id: newMessageId(), + command: trimmed, + createdAt: new Date().toISOString(), + }, + clearDraft: () => { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + scrollToEnd(); + }, + onUpdate: (submission) => { + setFeedbackSubmissionsByThreadKey((current) => { + const existing = current[routeThreadKey] ?? []; + const found = existing.some((entry) => entry.id === submission.id); + return { + ...current, + [routeThreadKey]: found + ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) + : [...existing, submission], + }; + }); + }, + upload: () => + uploadThreadFeedback({ + environmentId, + input: { + threadId: activeThread.id, + ...feedbackCommand, + }, + }), + }).finally(() => { + feedbackUploadsInFlightRef.current.delete(routeThreadKey); + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not send feedback to OpenAI", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + } + return; + } + const feedbackId = result.value.feedbackId; + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Feedback sent to OpenAI", + description: `Thread ID: ${feedbackId}`, + timeout: 0, + actionProps: { + children: "Copy ID", + onClick: () => { + void writeTextToClipboard(feedbackId, "Codex feedback thread ID").catch( + (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy thread ID", + description: chatActionErrorMessage(error), + }), + ); + }, + ); + }, + }, + }), + ); + return; + } if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, @@ -5596,7 +6022,28 @@ function ChatViewContent(props: ChatViewProps) { return new Set(current).add(threadIdForSend); }); } - if (isDraftHeroState && activeThreadKey) { + if (supportsAttachmentUploads && composerImagesSnapshot.length > 0) { + for (const image of composerImagesSnapshot) { + startAttachmentUpload({ environmentId, image }); + } + await awaitAttachmentUploads(composerImagesSnapshot.map((image) => image.id)); + if (getUploadedAttachments({ environmentId, images: composerImagesSnapshot }) === null) { + sendInFlightRef.current = false; + setThreadError(threadIdForSend, "Retry or remove failed image uploads before sending."); + return; + } + } + + const resolvedSubmissionIntent = + submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; + if ( + shouldDockDraftHeroForSubmission({ + isDraftHeroState, + activeThreadKey, + submissionIntent: resolvedSubmissionIntent, + }) && + activeThreadKey + ) { let resolveDockStarted: (() => void) | undefined; const dockStarted = new Promise((resolve) => { resolveDockStarted = resolve; @@ -5615,19 +6062,29 @@ function ChatViewContent(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree), messageId: messageIdForSend, + submissionIntent: resolvedSubmissionIntent, }); let turnStartSucceeded = false; try { const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), - })), + composerImagesSnapshot.map(async (image) => { + if (supportsAttachmentUploads) { + const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0]; + if (!uploaded) { + throw new Error(`Image '${image.name}' did not finish uploading.`); + } + return uploaded; + } + return { + type: "image" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + }; + }), ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ type: "image" as const, @@ -5798,6 +6255,18 @@ function ChatViewContent(props: ChatViewProps) { : {}), } : undefined; + beginLocalDispatch({ + preparingWorktree: false, + messageId: messageIdForSend, + submissionIntent: resolvedSubmissionIntent, + }); + const backgroundThreadRef = + resolvedSubmissionIntent === "background" + ? scopeThreadRef(activeThread.environmentId, threadIdForSend) + : null; + if (backgroundThreadRef) { + beginBackgroundDraftSubmissionByRef(backgroundThreadRef); + } const queuedTurnInput = { commandId: newCommandId(), threadId: threadIdForSend, @@ -5837,6 +6306,9 @@ function ChatViewContent(props: ChatViewProps) { inFlightThreadTurnSends.delete(messageIdForSend); } if (startResult._tag === "Failure") { + if (backgroundThreadRef) { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + } const error = squashAtomCommandFailure(startResult); const message = error instanceof Error ? error.message : String(error); if (outboxPersisted && isTransportConnectionErrorMessage(message)) { @@ -5855,7 +6327,57 @@ function ChatViewContent(props: ChatViewProps) { console.warn("[thread-turn-outbox] failed to remove delivered turn", error); }); turnStartSucceeded = true; + if (supportsAttachmentUploads) { + releaseAttachmentUploads(composerImagesSnapshot); + } acknowledgeActiveThreadWoke(); + if (backgroundThreadRef) { + markPromotedDraftThreadByRef(backgroundThreadRef); + try { + const nextDraft = await handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + resolveBackgroundDraftWorkspaceOptions({ + envMode: sendEnvMode, + branch: activeThreadBranch, + startFromOrigin, + }), + ); + if (nextDraft) { + finalizePromotedDraftThreadByRef(backgroundThreadRef); + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Started in background", + timeout: 5_000, + actionProps: { + children: "Open", + onClick: () => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(backgroundThreadRef), + }); + }, + }, + }), + ); + } else { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + } + } catch (error) { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + resetLocalDispatch(); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Task started in the background", + description: + error instanceof Error + ? `Could not open a fresh composer: ${error.message}` + : "Could not open a fresh composer.", + }), + ); + } + } } } @@ -5903,6 +6425,20 @@ function ChatViewContent(props: ChatViewProps) { } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); + if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) { + const failedDraftSession = getDraftSession(draftId); + if (failedDraftSession?.threadId === threadIdForSend) { + setLogicalProjectDraftThreadId( + failedDraftSession.logicalProjectKey, + scopeProjectRef(failedDraftSession.environmentId, failedDraftSession.projectId), + draftId, + { + threadId: newThreadId(), + createdAt: new Date().toISOString(), + }, + ); + } + } const message = error instanceof Error ? error.message : "Failed to send message."; if (isIdentityClaimRequiredMessage(message)) { requestIdentityClaimGate(activeThread.environmentId); @@ -6833,7 +7369,7 @@ function ChatViewContent(props: ChatViewProps) { configuredUrls={configuredPreviewUrls} visible onSendAnnotation={(annotation, image) => { - void onSend(undefined, { annotation, image }); + void onSend(undefined, "foreground", { annotation, image }); }} /> @@ -6888,7 +7424,7 @@ function ChatViewContent(props: ChatViewProps) { context={ isThreadOwnPullRequest( { - projectId: activeProject?.id ?? null, + projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null, repository: threadPullRequestRepository, number: activeThreadPr?.number ?? null, }, @@ -6961,9 +7497,9 @@ function ChatViewContent(props: ChatViewProps) { > {!rightPanelOpen ? panelLayoutControls : null} )} + {threadSyncPhase && !activeEnvironmentUnavailable ? ( + + ) : null}
+ { + event.preventDefault(); + }} + onClick={() => { + void submitAddProjectCloneFlow(); + }} + /> + } + > + {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} + + Enter + + + {remoteProjectButtonLabel ?? "Continue"} (Enter) + + ) : isBrowsing ? ( + + { + event.preventDefault(); + }} + onClick={() => { + if (relativePathNeedsActiveProject) { + return; + } + if (isCloneDestinationStep) { + void submitAddProjectCloneFlow(resolvedAddProjectPath); + } else { + void handleAddProject(resolvedAddProjectPath); + } + }} + /> + } + > + + {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} + + + {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} + + + + {submitActionLabel} ({addShortcutLabel}) + + + ) : !isSubmenu ? ( + + { + event.preventDefault(); + }} + onClick={() => { + setIncludeArchived((previous) => !previous); + }} + /> + } + > + + Archived + + + {includeArchived + ? "Hide archived threads from search" + : "Include archived threads in search"} + + + ) : null; + + const footerActionLabel = + addProjectCloneFlow?.step === "repository" + ? (remoteProjectButtonLabel ?? "Continue") + : !canSubmitBrowsePath || hasHighlightedBrowseItem + ? "Select" + : undefined; + + const footerTrailing = canOpenProjectFromFileManager ? ( + { + void handleOpenProjectFromFileManager(); + }} + > + {`Open in ${fileManagerName}`} + + ) : null; + return ( - {addProjectCloneFlow?.step === "repository" ? ( - - { - event.preventDefault(); - }} - onClick={() => { - void submitAddProjectCloneFlow(); - }} - /> - } - > - {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} - - Enter - - - - {remoteProjectButtonLabel ?? "Continue"} (Enter) - - - ) : isBrowsing ? ( - - { - event.preventDefault(); - }} - onClick={() => { - if (relativePathNeedsActiveProject) { - return; - } - if (isCloneDestinationStep) { - void submitAddProjectCloneFlow(resolvedAddProjectPath); - } else { - void handleAddProject(resolvedAddProjectPath); - } - }} - /> - } - > - - {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} - - - {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} - - - - {submitActionLabel} ({addShortcutLabel}) - - - ) : !isSubmenu ? ( - - { - event.preventDefault(); - }} - onClick={() => { - setIncludeArchived((previous) => !previous); - }} - /> - } - > - - Archived - - - {includeArchived - ? "Hide archived threads from search" - : "Include archived threads in search"} - - - ) : null} + {inputAccessory}
{remoteProjectContext ? ( @@ -2576,15 +2594,10 @@ function OpenCommandPaletteDialog(props: { Navigate - {addProjectCloneFlow?.step === "repository" ? ( - - Enter - {remoteProjectButtonLabel ?? "Continue"} - - ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( + {footerActionLabel ? ( Enter - Select + {footerActionLabel} ) : null} {isSubmenu ? ( @@ -2598,19 +2611,7 @@ function OpenCommandPaletteDialog(props: { Close
- {canOpenProjectFromFileManager ? ( - - ) : null} + {footerTrailing} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index fa1bbd8df467..1ed1bd3319f8 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -68,23 +68,26 @@ export const JujutsuIcon: Icon = (props) => { ); }; -export const GitLabIcon: Icon = (props) => ( +export const GitLabIcon = ({ + monochrome = false, + ...props +}: SVGProps & { readonly monochrome?: boolean }) => ( ); diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 460c6b66e9e5..e2d2394d934a 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -35,6 +35,7 @@ import { terminalStatusFromRunningIds, ThreadStatusLabel, ThreadWorktreeIndicator, + useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { ThreadIdentityMark } from "./identity/ParticipantStack"; import { @@ -95,7 +96,9 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; +import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; import { isTerminalFocused } from "../lib/terminalFocus"; import { cn, isMacPlatform } from "../lib/utils"; import { @@ -573,7 +576,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null + thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null ? vcsEnvironment.listStatus({ environmentId: thread.environmentId, input: { cwd: gitCwd }, @@ -614,11 +617,18 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr lastVisitedAt, }, }); - const pr = resolveThreadPr({ - threadBranch: thread.branch, - gitStatus: gitStatus.data ?? null, - }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const linkedPullRequestStatus = useLinkedThreadPullRequest( + thread.environmentId, + thread.linkedPullRequest, + ); + const pr = + thread.linkedPullRequest == null + ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data ?? null }) + : (linkedPullRequestStatus?.pr ?? null); + const prStatus = prStatusIndicator( + pr, + linkedPullRequestStatus?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, + ); // Lift PR state so parent hide-settled / shelf classification can auto-settle // merged/closed PRs (matches Sidebar V2 row reporting). const onChangeRequestState = useContext(SidebarChangeRequestStateContext); @@ -1688,6 +1698,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return result; } const draftStore = useComposerDraftStore.getState(); + releaseProjectDraftUploads(memberProjectRef); const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); @@ -5134,6 +5145,7 @@ export default function LegacySidebar() { const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const platform = navigator.platform; const shortcutModifiers = useShortcutModifierState(); + const terminalFocused = useTerminalFocus(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const [storedListMode, setStoredListMode] = useLocalStorage( @@ -5760,7 +5772,7 @@ export default function LegacySidebar() { [threadJumpCommandByKey], ); const sidebarShortcutContext = { - terminalFocus: false, + terminalFocus: terminalFocused, terminalOpen: routeTerminalOpen, modelPickerOpen: isModelPickerOpen(), }; diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 223960f8314d..2ee06a6b6620 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -31,6 +31,7 @@ import { parseWslDistroFromInstanceId, providerUpdateNotificationKey, resolveEnvironmentUpdateRowStatus, + shouldShowPrimaryProviderUpdateToast, type LocalEnvironmentProvidersInput, type LocalEnvironmentUpdateGroup, type LocalProviderUpdateOutcome, @@ -325,6 +326,21 @@ describe("provider update launch notification logic", () => { type: "loading", title: "Updating provider", }); + expect(shouldShowPrimaryProviderUpdateToast(view)).toBe(false); + }); + + it("keeps the initial prompt and terminal outcomes visible as toasts", () => { + expect( + shouldShowPrimaryProviderUpdateToast( + getProviderUpdateInitialToastView({ + updateProviders: [updateCandidate({ driver: driver("codex") })], + oneClickProviders: [updateCandidate({ driver: driver("codex") })], + }), + ), + ).toBe(true); + expect( + shouldShowPrimaryProviderUpdateToast(getProviderUpdateRejectedToastView(1, "boom")), + ).toBe(true); }); it("uses server failure state for failed progress", () => { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 55999d2a31d8..8d8abf73e312 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -231,6 +231,10 @@ export function getProviderUpdateInitialToastView(input: { }; } +export function shouldShowPrimaryProviderUpdateToast(view: ProviderUpdateToastView): boolean { + return view.phase !== "running"; +} + export function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { return { phase: "running", diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index 00112ccec198..639f07c38c13 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -16,8 +16,8 @@ import { getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, - getProviderUpdateRunningToastView, providerUpdateNotificationKey, + shouldShowPrimaryProviderUpdateToast, type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; import { hiddenToastActionProps, stackedThreadToast, toastManager } from "./ui/toast"; @@ -31,7 +31,6 @@ type ActiveProviderUpdateToast = | { readonly kind: "update"; readonly key: string; - readonly toastId: ProviderUpdateToastId; readonly providerInstanceIds: ReadonlySet; readonly providerCount: number; }; @@ -57,20 +56,16 @@ function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) ); } -function updateProviderUpdateToast(input: { - readonly toastId: ProviderUpdateToastId; +function addProviderUpdateToast(input: { readonly view: ProviderUpdateToastView; - readonly openSettings: () => void; + readonly openSettings: (toastId: ProviderUpdateToastId) => void; }) { if (input.view.type === "loading" || input.view.type === "success") { - toastManager.update(input.toastId, { + return toastManager.add({ type: input.view.type, title: input.view.title, description: input.view.description, timeout: 0, - // Base UI merges toast updates and omits `undefined` keys, so `undefined` - // would leave the prompt's Update button in place. Replace it with a - // defined empty action so the CTA cannot linger while the update runs. actionProps: hiddenToastActionProps, data: { hideCopyButton: true, @@ -79,11 +74,10 @@ function updateProviderUpdateToast(input: { : {}), }, }); - return; } - toastManager.update( - input.toastId, + let toastId!: ProviderUpdateToastId; + toastId = toastManager.add( stackedThreadToast({ type: input.view.type, title: input.view.title, @@ -91,7 +85,7 @@ function updateProviderUpdateToast(input: { timeout: 0, actionProps: { children: "Settings", - onClick: input.openSettings, + onClick: () => input.openSettings(toastId), }, actionVariant: "outline", data: { @@ -99,10 +93,7 @@ function updateProviderUpdateToast(input: { }, }), ); -} - -function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { - return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded"; + return toastId; } /** @@ -126,10 +117,10 @@ export function ProviderUpdatePrimaryNotification() { useEffect(() => { return () => { const activeToast = activeToastRef.current; - if (activeToast) { + if (activeToast?.kind === "prompt") { toastManager.close(activeToast.toastId); - activeToastRef.current = null; } + activeToastRef.current = null; }; }, []); @@ -149,10 +140,14 @@ export function ProviderUpdatePrimaryNotification() { const activeToast = activeToastRef.current; if (toastId !== undefined) { toastManager.close(toastId); - } else if (activeToast) { + } else if (activeToast?.kind === "prompt") { toastManager.close(activeToast.toastId); } - if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) { + if ( + activeToast && + (toastId === undefined || + (activeToast.kind === "prompt" && activeToast.toastId === toastId)) + ) { activeToastRef.current = null; } void navigate({ to: "/settings/providers" }); @@ -173,15 +168,12 @@ export function ProviderUpdatePrimaryNotification() { providers: activeProviders, providerCount: activeToast.providerCount, }); - updateProviderUpdateToast({ - toastId: activeToast.toastId, - view, - openSettings: () => openProviderSettings(activeToast.toastId), - }); - - if (isTerminalProviderUpdateToastView(view)) { - activeToastRef.current = null; + if (!shouldShowPrimaryProviderUpdateToast(view)) { + return; } + + addProviderUpdateToast({ view, openSettings: openProviderSettings }); + activeToastRef.current = null; }, [providers, openProviderSettings]); useEffect(() => { @@ -219,19 +211,15 @@ export function ProviderUpdatePrimaryNotification() { const providerCount = oneClickProviders.length; const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId)); - activeToastRef.current = { + const activeUpdate: ActiveProviderUpdateToast = { kind: "update", key: notificationKey, - toastId, providerInstanceIds, providerCount, }; + activeToastRef.current = activeUpdate; - updateProviderUpdateToast({ - toastId, - view: getProviderUpdateRunningToastView(providerCount), - openSettings, - }); + toastManager.close(toastId); void (async () => { const results = []; @@ -248,16 +236,15 @@ export function ProviderUpdatePrimaryNotification() { } const activeUpdateToast = activeToastRef.current; - if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { + if (activeUpdateToast !== activeUpdate) { return; } const failedMessage = firstFailedProviderUpdateMessage(results); if (failedMessage) { - updateProviderUpdateToast({ - toastId, + addProviderUpdateToast({ view: getProviderUpdateRejectedToastView(providerCount, failedMessage), - openSettings, + openSettings: openProviderSettings, }); activeToastRef.current = null; return; @@ -271,13 +258,8 @@ export function ProviderUpdatePrimaryNotification() { providers: updatedProviderSnapshots, providerCount, }); - updateProviderUpdateToast({ - toastId, - view, - openSettings, - }); - - if (isTerminalProviderUpdateToastView(view)) { + if (shouldShowPrimaryProviderUpdateToast(view)) { + addProviderUpdateToast({ view, openSettings: openProviderSettings }); activeToastRef.current = null; } })(); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 1812aa10260b..7b0ae9b4c201 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -2,7 +2,12 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { RightPanelTabs, surfaceShortcutActionForKey, tabMuteMenuItem } from "./RightPanelTabs"; +import { + RightPanelTabs, + surfaceShortcutActionForKey, + surfaceShortcutTargetsTypingContext, + tabMuteMenuItem, +} from "./RightPanelTabs"; function shortcutEvent( key: string, @@ -166,6 +171,33 @@ describe("surface shortcuts", () => { }); }); +describe("surface shortcut typing contexts", () => { + // Selector-aware stub: closest() answers only tokens the combined selector + // would actually match, mirroring how the browser resolves it. + const makeTarget = (matches: string | null) => ({ + closest(selectors: string) { + if (matches === null || !selectors.includes(matches)) return null; + return {}; + }, + }); + + it("treats form fields and every editable region as typing contexts", () => { + expect(surfaceShortcutTargetsTypingContext(makeTarget("input"))).toBe(true); + expect(surfaceShortcutTargetsTypingContext(makeTarget("textarea"))).toBe(true); + expect(surfaceShortcutTargetsTypingContext(makeTarget("select"))).toBe(true); + // The chat composer is a contenteditable that sits empty until a draft + // exists; launcher letters claimed from it redirected prompts into shells. + // The :not clause sees past contenteditable="false" islands to an editable + // host around them, so nested editors stay protected too. + expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]"))).toBe(true); + }); + + it("claims letters when focus sits outside any editable region", () => { + expect(surfaceShortcutTargetsTypingContext(null)).toBe(false); + expect(surfaceShortcutTargetsTypingContext(makeTarget(null))).toBe(false); + }); +}); + describe("RightPanelTabs audio indicator", () => { // A muted tab only shows the indicator while it is actually making sound: // arming mute on a quiet tab is deliberate and stays invisible until there diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f48c9ca07e4c..9d057a3d2980 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -10,7 +10,6 @@ import { TerminalSquare, Volume2, VolumeOff, - X, } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, @@ -33,6 +32,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "~/components/ui/menu"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; import { faviconUrlForOrigin } from "~/lib/favicon"; import { useTheme } from "~/hooks/useTheme"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; @@ -190,6 +190,23 @@ export function surfaceShortcutActionForKey< ); } +/** + * A focused editable is a typing context whether or not it has text yet: an + * empty chat composer at rest is still where the user's next keystrokes are + * meant to land, and claiming launcher letters from it would redirect prompts + * into whatever surface opens. The `:not` clause lets `closest` see past + * non-editable islands (`contenteditable="false"`) to an editable host around + * them, matching ComposerPendingUserInputPanel's typing guard. + */ +export function surfaceShortcutTargetsTypingContext( + target: { closest(selectors: string): unknown } | null, +): boolean { + return ( + target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != + null + ); +} + function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { return ( @@ -329,13 +346,7 @@ function RightPanelEmptyState(props: { if (!action) return; if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; const target = event.target; - if (target instanceof HTMLElement) { - if (target.closest("input, textarea, select")) return; - // An empty contenteditable (the chat composer at rest) does not - // count as typing; letters only become text once a draft exists. - const editable = target.isContentEditable ? target : target.closest("[contenteditable]"); - if (editable && (editable.textContent ?? "").trim().length > 0) return; - } + if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; event.preventDefault(); event.stopPropagation(); action.onClick(); @@ -812,29 +823,24 @@ export function RightPanelTabs(props: RightPanelTabsProps) { : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > - + ) : null} + {audio === "none" || !audioRuntimeTabId ? null : ( { }); }); +describe("animatePinnedLayoutChanges", () => { + const baseArgs: Parameters[0] = { + active: null, + containerId: "pinned-threads", + isDragging: false, + isSorting: false, + id: "thread-a", + index: 1, + items: ["thread-b", "thread-a"], + newIndex: 0, + previousItems: ["thread-a", "thread-b"], + previousContainerId: "pinned-threads", + transition: { duration: 200, easing: "ease" }, + wasDragging: true, + }; + + it("does not replay layout movement after the pointer is released", () => { + expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true); + expect(animatePinnedLayoutChanges(baseArgs)).toBe(false); + }); + + it("keeps layout movement while the user is sorting", () => { + expect(animatePinnedLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true); + }); +}); + describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3f995876468b..fa53339d46f2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { effectiveSettled, type ChangeRequestStateLike, @@ -57,7 +58,7 @@ export function resolveSidebarProjectBadgeColorIndex( } export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; -export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; +export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. Each prewarmed // thread holds a live, fully hydrated detail subscription (all messages and @@ -89,6 +90,12 @@ export type SidebarThreadWorktreeSection = threads: SidebarThreadSummary[]; }; +// The list already reaches its destination through sortable transforms while +// the pointer is down. dnd-kit's default also animates the committed DOM order +// after release, replaying the same movement across every affected row. +export const animatePinnedLayoutChanges: AnimateLayoutChanges = (args) => + args.isSorting ? defaultAnimateLayoutChanges(args) : false; + type SidebarProject = { id: string; title: string; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index eb9188fa92e2..ec5e862a4db9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -95,11 +95,13 @@ import { threadTraversalDirectionFromCommand, } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; +import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { readLocalApi } from "../localApi"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { @@ -138,14 +140,12 @@ import { requestIdentityClaimGate, } from "./identity/IdentityClaimGate"; import { - claimPersonIdForEnvironment, DEFAULT_OWNERSHIP_RELATION, isOwnershipRelation, - threadMatchesMine, - type OwnershipRelation, } from "@t3tools/client-runtime/state/identity"; import { identityClaimPersonIdByEnvironmentAtom } from "../state/identity"; import { + animatePinnedLayoutChanges, SETTLED_TAIL_INITIAL_COUNT, SETTLED_TAIL_PAGE_COUNT, buildBulkTitleRegenerationContextMenuItem, @@ -166,6 +166,7 @@ import { sortPinnedThreadsForSidebar, sortSettledThreadsForSidebar, sortThreadsForSidebar, + useThreadJumpHintVisibility, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { @@ -180,6 +181,7 @@ import { threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, type TerminalStatusIndicator, + useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, @@ -194,11 +196,10 @@ import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { resolveDriverUsage, usageDotFillClass, usageDotRingColor } from "../aiUsageState"; import { useAiUsageSnapshot } from "../hooks/useAiUsageSnapshot"; import { - deriveProviderInstanceEntries, + deriveProviderEntriesByEnvironment, shouldShowInstanceBadge, type ProviderInstanceEntry, } from "../providerInstances"; -import { primaryServerProvidersAtom } from "../state/server"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; @@ -322,6 +323,8 @@ function WorkingDuration(props: { startedAt: string | null }) { ); } +const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); + function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } @@ -537,6 +540,7 @@ function SortablePinnedThreadRow(props: { }) { const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.id, + animateLayoutChanges: animatePinnedLayoutChanges, }); return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } @@ -739,6 +743,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { // The /draft/$draftId route redirects home on its own when the draft // it renders disappears, so discarding the open draft needs no // special-casing here. + releaseComposerDraftUploads(draftId); clearDraftThread(draftId); }, [clearDraftThread], @@ -786,13 +791,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; - // Renders the pin glyph. Pinned cards keep the full settle/snooze quick - // actions: settling clears the pin server-side, and snoozing hides the - // card until wake with the pin intact underneath. The glyph is also the - // in-row pin state cue (the pinned block has no header), so it always - // shows while pinned; it only becomes a clickable unpin quick-action once - // the pinning capability is confirmed, and stays a passive marker while - // the descriptor is not loaded. Pinning itself lives in the context menu. + // Pinned threads show the same pin marker in active, settled, and snoozed + // rows. The marker can unpin the thread when the server supports pinning. pinningSupported: boolean; isPinned: boolean; // Present only on pinned cards whose server supports reordering: dnd-kit @@ -890,6 +890,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // timestamp counts as never-visited — corrupt local data must not eat // the wake signal. const gitCwd = thread.worktreePath ?? props.projectCwd; + const linkedPullRequestStatus = useLinkedThreadPullRequest( + thread.environmentId, + thread.linkedPullRequest, + ); const gitStatus = useEnvironmentQuery( (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.listStatus({ @@ -904,6 +908,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, + linkedPullRequest: thread.linkedPullRequest, + linkedPullRequestStatus, }); // A woken thread reappears at its original position (the sort is @@ -1000,6 +1006,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, + linkedPullRequest: thread.linkedPullRequest, + linkedPullRequestStatus, }); const prStatus = prStatusIndicator(pr, prProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; @@ -1009,15 +1017,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, + linkedPullRequest: thread.linkedPullRequest, + linkedPullRequestStatus, }); if (nextSnapshot === undefined) return; onChangeRequestSnapshot(threadKey, nextSnapshot); }, [ changeRequestSnapshot, gitStatus.data, + linkedPullRequestStatus, onChangeRequestSnapshot, retainTerminalOnBranchMismatch, thread.branch, + thread.linkedPullRequest, threadKey, ]); @@ -1312,6 +1324,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; + const pinIndicator = props.isPinned ? ( + props.pinningSupported ? ( + + + } + > + + + Unpin thread + + ) : ( + + ) + ) : null; if (variant === "slim") { return ( @@ -1353,6 +1390,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> {title} + {pinIndicator} {terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1418,17 +1456,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - + + + } + > + + + Un-settle thread + ) : ( )} -
+
{terminalGroup.terminalIds.map((terminalId) => { const isActive = terminalId === resolvedActiveTerminalId; - const closeTerminalLabel = `Close ${ - terminalLabelById.get(terminalId) ?? "terminal" - }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; + const terminalLabel = terminalLabelById.get(terminalId) ?? "Terminal"; + const closeTerminalLabel = `Close ${terminalLabel}${ + isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : "" + }`; return (
- {showGroupHeaders && ( - + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} + > + confirmCloseTerminal(terminalId)} + tooltip={closeTerminalLabel} + > + + - {normalizedTerminalIds.length > 1 && ( - - confirmCloseTerminal(terminalId)} - aria-label={closeTerminalLabel} - /> - } - > - - - - {closeTerminalLabel} - - - )}
); })} diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 99e4bb8a7210..983ab26001e8 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -71,7 +71,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { className={cn( "flex items-center justify-between gap-2 rounded-xl", expanded && - "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]", + "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--contrast-foreground)_2.5%,var(--background))]", )} >
@@ -3089,6 +3246,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
@@ -3167,12 +3325,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> ) : null}
- {visibleTasksProgress && - visibleTaskSteps && - !isTasksDrawerOpen && - !props.externalDrawerAttached && - !showComposerTopDrawer && - !isComposerCollapsedMobile ? ( + {showShoulderTabs && visibleTasksProgress && visibleTaskSteps ? ( 0} @@ -3182,10 +3335,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) steps={visibleTaskSteps} /> ) : null} - {!props.externalDrawerAttached && - !showComposerTopDrawer && - !isTasksDrawerOpen && - !isComposerCollapsedMobile ? ( + {showShoulderTabs ? ( - removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) - } + {...(supportsAttachmentUploads + ? { + uploadsByImageId, + onRetryUpload: (image: ComposerImageAttachment) => + retryAttachmentUpload({ environmentId, image }), + } + : {})} + onRemove={(annotationId) => { + releaseAttachmentUpload(annotationId); + removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId); + }} onExpandImage={(imageId) => { const preview = buildExpandedImagePreview(composerImages, imageId); if (preview) onExpandImage(preview); @@ -3352,66 +3510,104 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (annotation) => annotation.id === image.id, ), ) - .map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} + {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + {upload?.status === "uploading" && ( + + {formatAttachmentUploadProgress(upload.progress)} + + )} + {upload?.status === "failed" && ( + + + retryAttachmentUpload({ environmentId, image }) + } + aria-label={`Retry upload for ${image.name}`} + /> + } + > + + + + {upload.reason} + + + )} + +
+ ); + })} )} @@ -3632,6 +3828,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + compactDisabled={ + compactDisabled || noProviderAvailable || isSendBusy || isConnecting + } + compactDisabledReason={resolvedCompactDisabledReason} + {...(selectedProvider === "claudeAgent" + ? { onCompactContext: compactThreadContext } + : {})} /> diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 237de68a86b0..e939e48b5baa 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -18,6 +18,7 @@ import { ChevronDownIcon } from "lucide-react"; import { memo, useCallback, + useEffect, useMemo, useRef, useState, @@ -25,6 +26,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import GitActionsControl from "../GitActionsControl"; +import { isTrailingDoubleClick } from "../Sidebar.logic"; import { type DraftId } from "~/composerDraftStore"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; @@ -103,6 +105,14 @@ export function resolveRenameCommit(input: { return { action: "commit", title: trimmed }; } +// How long a click on the thread title waits before opening the action menu, +// so a double-click-to-rename can cancel it first. Only the native desktop +// menu needs this: it swallows input while open, so the wait must cover the +// OS double-click interval. The browser fallback menu keeps seeing DOM +// events (the second click dismisses it and dblclick still fires), so it +// opens immediately. +const TITLE_MENU_OPEN_DELAY_MS = 500; + export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; @@ -285,28 +295,77 @@ export const ChatHeader = memo(function ChatHeader({ }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], ); - const { openMenu } = useThreadActionMenu({ + const { openMenu, closeMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, changeRequest, onStartRename: startRename, }); const titleButtonRef = useRef(null); - const openMenuFromTitle = useCallback(() => { + const titleMenuTimerRef = useRef(null); + const cancelPendingTitleMenu = useCallback(() => { + if (titleMenuTimerRef.current === null) return; + clearTimeout(titleMenuTimerRef.current); + titleMenuTimerRef.current = null; + }, []); + // Drop a pending menu-open when the thread changes or the header unmounts, + // so it can never fire for a thread the user already left. + useEffect( + () => () => { + cancelPendingTitleMenu(); + }, + [activeThreadId, cancelPendingTitleMenu], + ); + const openTitleMenuNow = useCallback(() => { + cancelPendingTitleMenu(); const rect = titleButtonRef.current?.getBoundingClientRect(); if (!rect) return; openMenu({ x: rect.left, y: rect.bottom + 4 }); - }, [openMenu]); + }, [cancelPendingTitleMenu, openMenu]); + const openMenuFromTitle = useCallback( + (event: ReactMouseEvent) => { + // The trailing click of a double-click belongs to rename, not the menu. + if (isTrailingDoubleClick(event.detail)) return; + // Keyboard activation and the explicit chevron affordance can never be + // the first half of a double-click, so they open without waiting. + const clickedChevron = + (event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null; + if (event.detail === 0 || clickedChevron || window.desktopBridge === undefined) { + openTitleMenuNow(); + return; + } + // Stay pending long enough for dblclick to cancel the open before the + // native menu appears and swallows the second click. + cancelPendingTitleMenu(); + titleMenuTimerRef.current = window.setTimeout(() => { + titleMenuTimerRef.current = null; + openTitleMenuNow(); + }, TITLE_MENU_OPEN_DELAY_MS); + }, + [cancelPendingTitleMenu, openTitleMenuNow], + ); + const handleTitleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + // The chevron is the explicit menu affordance; only the title text renames. + if ((event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null) return; + cancelPendingTitleMenu(); + closeMenu(); + startRename(); + }, + [cancelPendingTitleMenu, closeMenu, startRename], + ); const handleHeaderContextMenu = useCallback( (event: ReactMouseEvent) => { if (!isServerThread || renamingTitle !== null) return; // The right-side controls (git, scripts, open-in) keep their own // behavior; only the breadcrumb area opens the thread menu. if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return; + cancelPendingTitleMenu(); event.preventDefault(); openMenu({ x: event.clientX, y: event.clientY }); }, - [isServerThread, openMenu, renamingTitle], + [cancelPendingTitleMenu, isServerThread, openMenu, renamingTitle], ); const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent) => { @@ -382,6 +441,8 @@ export const ChatHeader = memo(function ChatHeader({ aria-label={`Thread actions for ${activeThreadTitle}`} aria-haspopup="menu" onClick={openMenuFromTitle} + onDoubleClick={handleTitleDoubleClick} + onBlur={cancelPendingTitleMenu} className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" /> } @@ -389,6 +450,7 @@ export const ChatHeader = memo(function ChatHeader({

{activeThreadTitle}

diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index f07836ab32a6..33d0d17eed7d 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -53,6 +53,7 @@ describe("ComposerBannerStack", () => { 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"'); @@ -76,4 +77,32 @@ describe("ComposerBannerStack", () => { expect(markup).toContain("branch-surface"); expect(markup).toContain("branch-actions"); }); + + it("renders a disabled compaction action on the shared accessible banner surface", () => { + const markup = renderToStaticMarkup( +