diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index 6a7fc4a6d5b5f..d7ebb1a0790cd 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -5,6 +5,8 @@ // node ci-changes.cjs --paths --full run every Linux job, gate only the macOS lint // node ci-changes.cjs changed paths unknown: run everything // +// scripts/check-changed.ts requires the same decisions so local runs match CI. +// // Every changed path maps to workspace packages, which then pull in their // dependents from package.json. A path this file does not recognize runs // everything, so a new root file or directory can only cost minutes. @@ -201,4 +203,6 @@ function main() { for (const [key, value] of Object.entries(outputs)) console.log(`${key}=${value}`); } -main(); +module.exports = { readWorkspace, decide }; + +if (require.main === module) main(); diff --git a/.github/scripts/ci-changes.d.cts b/.github/scripts/ci-changes.d.cts new file mode 100644 index 0000000000000..afe6f61339362 --- /dev/null +++ b/.github/scripts/ci-changes.d.cts @@ -0,0 +1,27 @@ +interface WorkspacePackage { + readonly name: string; + readonly dir: string; + readonly hasTest: boolean; + readonly deps: ReadonlyArray; +} + +export function readWorkspace(): Array; + +export function decide( + files: ReadonlyArray | null, + packages: ReadonlyArray, + full: boolean, +): { + affected: Array; + outputs: { + typecheck: boolean; + build: boolean; + test: boolean; + test_packages: string; + test_web: boolean; + test_server: boolean; + rust: boolean; + release_smoke: boolean; + mobile_native: boolean; + }; +}; diff --git a/.github/scripts/vercel-deployment-output.test.cjs b/.github/scripts/vercel-deployment-output.test.cjs new file mode 100644 index 0000000000000..a249653350f22 --- /dev/null +++ b/.github/scripts/vercel-deployment-output.test.cjs @@ -0,0 +1,87 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const { mkdtempSync, readFileSync, rmSync, existsSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); +const { test } = require("node:test"); + +const release = readFileSync(join(__dirname, "../workflows/release.yml"), "utf8"); +const preview = readFileSync(join(__dirname, "../workflows/web-preview.yml"), "utf8"); +const stepPattern = + / name: (?:Build staged deployment|Deploy preview)\n shell: bash\n run: \|\n((?: .*\n|\n)+)/g; +const steps = [ + ...Array.from(release.matchAll(stepPattern), (match, index) => [ + index === 0 ? "hosted web" : "marketing", + match[1], + ]), + ...Array.from(preview.matchAll(stepPattern), (match) => ["web preview", match[1]]), +]; +assert.equal(steps.length, 3, "Expected both release steps and the preview step"); + +const url = "https://t3code-fixture-pinglabs.vercel.app"; +const warning = 'Error while parsing config file: "/runner/t3code/pnpm-lock.yaml"'; + +function runStep(script, stdout, exitCode = 0) { + const directory = mkdtempSync(join(tmpdir(), "t3-vercel-output-")); + const outputFile = join(directory, "output"); + try { + const result = spawnSync( + "bash", + [ + "-c", + 'vp() { printf "%s\\n" "$FIXTURE_STDOUT"; return "$FIXTURE_EXIT"; }\n' + + 'curl() { printf \'{"id":"fixture-project"}\\n\'; }\n' + + script.replace(/^ /gm, "").replace(/\$\{\{.*?\}\}/g, "fixture"), + ], + { + encoding: "utf8", + env: { + PATH: process.env.PATH, + VERCEL_TOKEN: "fixture-token", + VERCEL_ORG_ID: "fixture-org", + VERCEL_PROJECT_ID: "fixture-project", + GITHUB_OUTPUT: outputFile, + FIXTURE_STDOUT: stdout, + FIXTURE_EXIT: String(exitCode), + }, + }, + ); + assert.ifError(result.error); + return { + ...result, + output: existsSync(outputFile) ? readFileSync(outputFile, "utf8") : "", + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +for (const [name, script] of steps) { + for (const [label, stdout] of [ + ["plain URL", url], + ["lockfile warning", `${warning}\n${url}`], + ["trailing diagnostic", `${url}\nDone`], + ]) { + test(`${name}: saves one deployment URL after ${label}`, () => { + const result = runStep(script, stdout); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + result.output.split("\n").filter((line) => !line.startsWith("project_id=")), + [`deployment_url=${url}`, ""], + ); + }); + } + + for (const [label, stdout, exitCode] of [ + ["missing URL", warning, 0], + ["invalid URL", "https://example.invalid/deployment", 0], + ["multiple URLs", `${url}\nhttps://other.vercel.app`, 0], + ["failed deployment", url, 1], + ]) { + test(`${name}: rejects ${label} without saving output`, () => { + const result = runStep(script, stdout, exitCode); + assert.notEqual(result.status, 0); + assert.equal(result.output, ""); + }); + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e9595eee20c6..d0cb88c2e9798 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,7 +253,7 @@ jobs: run: python3 -B .github/scripts/stage-preview-bundle.test.py - name: Test release checks - run: node --test .github/scripts/check-nightly-release.test.cjs + run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs - name: Finish installing browser secret helper build libraries timeout-minutes: 5 diff --git a/.github/workflows/deploy-bkt3.yml b/.github/workflows/deploy-bkt3.yml index bf4aac893a5b4..538a93b803876 100644 --- a/.github/workflows/deploy-bkt3.yml +++ b/.github/workflows/deploy-bkt3.yml @@ -28,7 +28,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v6 with: - version: 11.10.0 + version: 12.14.0 - name: Set up Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/deploy-expbkt3.yml b/.github/workflows/deploy-expbkt3.yml index 3c46cc26267c3..7f26ef44382cf 100644 --- a/.github/workflows/deploy-expbkt3.yml +++ b/.github/workflows/deploy-expbkt3.yml @@ -29,7 +29,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v6 with: - version: 11.10.0 + version: 12.14.0 - name: Set up Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index 91b70d8c45bd8..c29b325080266 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -30,7 +30,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v6 with: - version: 11.10.0 + version: 12.14.0 - name: Set up Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 65cfa08321c87..516a0a7785a95 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -253,18 +253,13 @@ jobs: if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client - # The single-executable is built with a Node that supports --build-sea - # (25.7+); the repo itself stays on the engines.node version. A native - # build injects into the runner's own Node; a Windows target downloads its - # node.exe. + # Setup Vite+ selects the exact engines.node version from package.json. + # Native builds embed that executable; cross-target builds download the + # same version for their target. - name: Build CLI single-executable if: inputs.cli_archive shell: bash env: - # The exact version, not a major: vp downloads it from nodejs.org/dist on - # the runner, and only exact versions have a dist directory. Keep in - # step with SEA_NODE_VERSION in apps/server/vite.config.ts. - VP_NODE_VERSION: "26.8.2" # The executable bundles the server again, so it needs the same stamp # as release.yml's JS bundle. T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53ed4b6298b87..3d3baf127a939 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1072,7 +1072,7 @@ jobs: fi echo "Building hosted web app for $channel_name channel." - deployment_url="$( + deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --prod \ @@ -1091,6 +1091,13 @@ jobs: --build-env "VITE_HOSTED_APP_URL=$router_url" \ --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" + printf '%s\n' "$deployment_output" + # Vercel can print lockfile warnings to stdout before the deployment URL. + deployment_url="$(printf '%s\n' "$deployment_output" | grep -E '^https://[[:alnum:].-]+\.vercel\.app$')" + if [[ "$deployment_url" == *$'\n'* ]]; then + echo "Vercel returned multiple deployment URLs." >&2 + exit 1 + fi echo "Staged $deployment_url." echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" @@ -1219,7 +1226,7 @@ jobs: )" export VERCEL_PROJECT_ID - deployment_url="$( + deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --prod \ @@ -1228,6 +1235,13 @@ jobs: --token "$VERCEL_TOKEN" \ --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" )" + printf '%s\n' "$deployment_output" + # Vercel can print lockfile warnings to stdout before the deployment URL. + deployment_url="$(printf '%s\n' "$deployment_output" | grep -E '^https://[[:alnum:].-]+\.vercel\.app$')" + if [[ "$deployment_url" == *$'\n'* ]]; then + echo "Vercel returned multiple deployment URLs." >&2 + exit 1 + fi echo "Staged $deployment_url." echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" echo "project_id=$VERCEL_PROJECT_ID" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index fb7a93d63e16f..ab903caba617e 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -77,13 +77,20 @@ jobs: vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" - deployment_url="$( + deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --yes \ --token "$VERCEL_TOKEN" \ --scope "$vercel_scope" )" + printf '%s\n' "$deployment_output" + # Vercel can print lockfile warnings to stdout before the deployment URL. + deployment_url="$(printf '%s\n' "$deployment_output" | grep -E '^https://[[:alnum:].-]+\.vercel\.app$')" + if [[ "$deployment_url" == *$'\n'* ]]; then + echo "Vercel returned multiple deployment URLs." >&2 + exit 1 + fi echo "Deployed $deployment_url" echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs new file mode 100644 index 0000000000000..ff75832369d8d --- /dev/null +++ b/.pnpmfile.cjs @@ -0,0 +1,21 @@ +// Windows WSL dev mode runs the server under Linux from the same node_modules +// as the Windows client, so Windows installs also need Linux x64 glibc native +// modules. Other platforms install only their own binaries; setting this +// everywhere added ~2.2 GB of foreign binaries to every macOS install. +// Release scripts stage their own installs with explicit architectures. +module.exports = { + hooks: { + updateConfig(config) { + // oxlint-disable-next-line t3code/no-global-process-runtime -- pnpm loads this file before any Effect runtime exists. + if (process.platform !== "win32") return config; + return { + ...config, + supportedArchitectures: { + os: ["current", "linux"], + cpu: ["current", "x64"], + libc: ["current", "glibc"], + }, + }; + }, + }, +}; diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000000000..e3576c995f40a --- /dev/null +++ b/.vercelignore @@ -0,0 +1,2 @@ +# Vercel's default .pnp* rule also excludes pnpm's hook, whose checksum is in the lockfile. +!/.pnpmfile.cjs diff --git a/AGENTS.md b/AGENTS.md index 71908d09fc786..408b8d72808ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,7 @@ The most common defect in this repo is a change that works on the path you teste ## Dev servers - `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. +- Setup runs in the background. While `.t3/setup-running` names a live PID, wait for it and do not run `vp i`. It ends with `.t3/setup-done` or `.t3/setup-failed` (with the error tail). With no marker at all, setup never ran here, so run `vp i` yourself. - `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. - Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings. @@ -168,15 +169,17 @@ The most common defect in this repo is a change that works on the path you teste An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- Run `vp run migrate-dev-db` with your dev server stopped. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. +- The first `vp run dev` in a worktree does this for you when its database is missing. `--no-seed` or `T3CODE_DEV_SEED=0` skips it. +- Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. - Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - 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. ## Verifying -- Smallest proof that the change works. `vp test run ` for the tests you touched (`-t ""` in big files), `vp lint `, and `vp run typecheck` in the package you changed. Not `npx tsc`; it is 3x slower than tsc-rs. -- Run `vp run knip:check` (about 35 s) when you add, remove, or rename exports, files, or dependencies. +- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, and tests beside changes. CI still runs the full test suites. +- While you iterate, run `vp test run `. For big files, add `-t ""`. +- Do not use `npx tsc`. Use `vp run typecheck`; it uses tsc-rs, which is 3x faster. - Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. - Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. @@ -184,6 +187,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - The loop is: push the branch, open the PR, watch CI remotely with `gh pr checks --watch` and `gh run view --log-failed`, then push fixes from this box. The fork's deploy workflows run on GitHub-hosted `ubuntu-latest`, so they are real offload. - Check headroom with `free -g` and `uptime` before anything long. Under ~6G available or load above ~12, move the work to CI instead. - Backend behavior changes ship with focused tests for that behavior. +- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in the module's existing test file), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. - The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely: - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. @@ -258,7 +262,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ - `packages/contracts` - Effect/Schema contracts plus small derived helpers. No heavy runtime logic. - `packages/shared` - shared runtime utils, subpath exports, no barrel. - `packages/client-runtime` - client code shared by web and mobile. -- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. +- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. If it is missing (sparse checkout), read a file with `git show origin/main:.repos/`. ## Taste diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fded4eec9e417..c652ccabc768d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,7 +7,6 @@ "scripts": { "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", - "typecheck": "tsc-rs --noEmit", "test": "vp test run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" }, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 9dde45da4890b..dd36b21ca9e70 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -63,6 +63,11 @@ export default defineConfig({ dependsOn: ["t3#build"], cache: false, }, + // tsc-rs rewrites the tsbuildinfo it reads, which keeps a task out of the cache. + typecheck: { + command: "tsc-rs --noEmit", + cache: { input: [{ auto: true }, "!*.tsbuildinfo"] }, + }, }, }, pack: [ @@ -165,6 +170,9 @@ export default defineConfig({ // The Windows lane runs workspace suites concurrently; filesystem-heavy // desktop integration tests can exceed Vitest's 5 second default there. testTimeout: 15_000, - setupFiles: ["../../packages/shared/src/testing/longTempDir.ts"], + setupFiles: [ + "../../packages/shared/src/testing/longTempDir.ts", + "../../packages/shared/src/testing/providerEnv.ts", + ], }, }); diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 5ba3da4fba109..15a8a8f916333 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -1,5 +1,11 @@ import { defineConfig } from "astro/config"; +// Astro loads env with an empty prefix, and under `vp run` Vite+'s loadEnv then reports every +// variable to the task cache. Any env difference (TERM, VP_RAW_SUBCOMMAND, an agent's session +// vars) would rerun `astro check`. Without the runner client, loadEnv reads process.env directly; +// file tracking does not use this client. +delete process.env.VP_RUN_NODE_CLIENT_PATH; + export default defineConfig({ site: "https://t3.codes", server: { diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 6eb4a6e41f69a..36dc282e89b47 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -7,8 +7,7 @@ "stage:install-scripts": "node scripts/stage-install-scripts.mjs", "dev": "node scripts/stage-install-scripts.mjs && astro dev", "build": "node scripts/stage-install-scripts.mjs && astro build", - "preview": "astro preview", - "typecheck": "astro check" + "preview": "astro preview" }, "dependencies": { "@t3tools/shared": "workspace:*", diff --git a/apps/marketing/vite.config.ts b/apps/marketing/vite.config.ts new file mode 100644 index 0000000000000..f927357c438df --- /dev/null +++ b/apps/marketing/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite-plus"; + +// Astro reads astro.config.mjs. This file only configures `vp run` tasks. +export default defineConfig({ + run: { + tasks: { + // `astro check` regenerates .astro/*.d.ts and then reads them, which + // keeps a task out of the cache. Its real inputs are tracked as usual. + typecheck: { + command: "astro check", + cache: { input: [{ auto: true }, "!.astro/**"] }, + }, + }, + }, +}); diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz deleted file mode 100644 index 581307cf91be1..0000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz new file mode 100644 index 0000000000000..071a4022a7b3f Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz differ diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift index 936efe7676e95..b970936b1fe8f 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -69,6 +69,7 @@ final class T3LayoutMetricsView: ExpoView { } var verticalBarEdge = "none" var regions: [[String: Any]] = [] + var bottomBarInset: CGFloat? #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft @@ -77,6 +78,8 @@ final class T3LayoutMetricsView: ExpoView { case .trailing: verticalBarEdge = rtl ? "left" : "right" default: break } + // Where UIKit floats a bar as tall as the app's bottom controls, level with its tab bar. + bottomBarInset = edgeInsets(for: .bar(onEdge: UIRectEdge.bottom, extent: 48)).bottom for kind in [UIView.ReservedRegion.Kind.division, .occlusion] { for region in reservedRegions(kind: kind) where region.isActive { let frame = region.frame.intersection(bounds) @@ -90,7 +93,7 @@ final class T3LayoutMetricsView: ExpoView { } } #endif - let metrics: [String: Any] = [ + var metrics: [String: Any] = [ "width": bounds.width, "height": bounds.height, "hasHinge": hasHinge, "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", @@ -101,6 +104,7 @@ final class T3LayoutMetricsView: ExpoView { ], "reservedRegions": regions, ] + metrics["bottomBarInset"] = bottomBarInset let next = metrics as NSDictionary guard lastMetrics?.isEqual(next) != true else { return } lastMetrics = next diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a2c74dfa92bbb..80bb4023c6708 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -137,7 +137,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.30.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.36.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 0c5f593dddf0f..e7a98fce3ac38 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -159,7 +159,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { deriveLayout } from "./lib/layout"; import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; -import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; +import { HORIZONTAL_FORM_SHEET_OPTIONS, formSheetDetents } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; @@ -220,6 +220,15 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { const NESTED_NAVIGATOR_OPTIONS = { nativeContentInsetHorizontally: false, headerShown: false }; +// Thread settings keep the Duo's vertical bar so cancel, search, filter and confirm sit in the +// rail. The picker stack spans the sheet so its header reaches the rail, and insets its content. +const THREAD_SETTINGS_SHEET_OPTIONS = { + presentation: "formSheet" as const, + sheetAllowedDetents: [1], + sheetGrabberVisible: true, + nativeContentInsetHorizontally: false, +}; + const SettingsContentStack = createV5SheetStackNavigator({ initialRouteName: "Settings", screenOptions: { @@ -378,9 +387,7 @@ const SettingsContentStack = createV5SheetStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, + ...THREAD_SETTINGS_SHEET_OPTIONS, }), }, }), @@ -532,9 +539,7 @@ const NewTaskSheetStack = createNativeStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, + ...THREAD_SETTINGS_SHEET_OPTIONS, }), }, }), @@ -760,8 +765,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: PullRequestFilesSheet, options: { ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.6, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.6), sheetGrabberVisible: true, sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, }, @@ -770,8 +775,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: PullRequestReviewersScreen, options: { ...DETAIL_HEADER_OPTIONS, - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.75, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.75), sheetGrabberVisible: true, headerBackVisible: false, }, @@ -785,8 +790,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ options: { ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : { ...SHEET_GLASS_HEADER_OPTIONS, ...FORM_SHEET_PRESENTATION_OPTIONS }), - sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.75, 0.92], + : { ...SHEET_GLASS_HEADER_OPTIONS, ...HORIZONTAL_FORM_SHEET_OPTIONS }), + sheetAllowedDetents: Platform.OS === "android" ? undefined : formSheetDetents(0.75), sheetGrabberVisible: Platform.OS !== "android", }, }), @@ -794,8 +799,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: PullRequestReviewSheet, options: { ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.75, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.75), sheetGrabberVisible: true, }, }), @@ -843,8 +848,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ // formSheet; use a full-screen modal there instead. ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : FORM_SHEET_PRESENTATION_OPTIONS), - sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], + : HORIZONTAL_FORM_SHEET_OPTIONS), + sheetAllowedDetents: Platform.OS === "android" ? undefined : formSheetDetents(0.55), sheetGrabberVisible: Platform.OS !== "android", }, }), @@ -865,7 +870,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ // cannot be hosted inside a formSheet there. ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : FORM_SHEET_PRESENTATION_OPTIONS), + : HORIZONTAL_FORM_SHEET_OPTIONS), sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], sheetGrabberVisible: Platform.OS !== "android", headerShown: false, @@ -878,7 +883,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ // Same Android constraint as the other keyboard-driven sheets. ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : FORM_SHEET_PRESENTATION_OPTIONS), + : HORIZONTAL_FORM_SHEET_OPTIONS), sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.6, 0.95], sheetGrabberVisible: Platform.OS !== "android", headerShown: false, @@ -891,7 +896,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ options: { ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : FORM_SHEET_PRESENTATION_OPTIONS), + : HORIZONTAL_FORM_SHEET_OPTIONS), sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.6, 0.95], sheetGrabberVisible: Platform.OS !== "android", headerShown: false, @@ -904,7 +909,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ options: { ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : FORM_SHEET_PRESENTATION_OPTIONS), + : HORIZONTAL_FORM_SHEET_OPTIONS), sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.7, 0.95], sheetGrabberVisible: Platform.OS !== "android", headerShown: false, @@ -946,36 +951,34 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, + ...THREAD_SETTINGS_SHEET_OPTIONS, }), }, }), ThreadQueue: createNativeStackScreen({ screen: ThreadQueueSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, headerShown: false, - sheetAllowedDetents: [0.65, 0.95], + sheetAllowedDetents: formSheetDetents(0.65, 0.95), sheetGrabberVisible: true, }, }), ThreadBackgroundWork: createNativeStackScreen({ screen: ThreadBackgroundWorkSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, headerShown: false, - sheetAllowedDetents: [0.5, 0.9], + sheetAllowedDetents: formSheetDetents(0.5, 0.9), sheetGrabberVisible: true, }, }), ThreadAgents: createNativeStackScreen({ screen: ThreadAgentsSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, headerShown: false, - sheetAllowedDetents: [0.5, 0.9], + sheetAllowedDetents: formSheetDetents(0.5, 0.9), sheetGrabberVisible: true, }, }), @@ -983,8 +986,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitOverviewSheet, linking: `${THREAD_LINKING_PREFIX}/git`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -992,8 +996,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: ThreadPullRequestsSheet, linking: `${THREAD_LINKING_PREFIX}/pull-requests`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -1002,8 +1007,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/git/commit`, options: { ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.8, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.8), sheetGrabberVisible: true, }, }), @@ -1011,8 +1016,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitBranchesSheet, linking: `${THREAD_LINKING_PREFIX}/git/branches`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -1020,8 +1026,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitConfirmSheet, linking: `${THREAD_LINKING_PREFIX}/git-confirm`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.45, 0.7], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }, }), @@ -1054,8 +1060,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), title: "Set up T3 Connect", gestureEnabled: true, - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.6, 0.95], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.6, 0.95), sheetGrabberVisible: true, }, }), @@ -1067,7 +1073,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const, headerShown: false } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }), @@ -1081,7 +1087,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ // native header stack mounts without remounting the pairing form. ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), title: "Add Environment", - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }, @@ -1099,6 +1105,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ), options: { ...NESTED_NAVIGATOR_OPTIONS, + sheetPrefersHorizontalBars: true, gestureEnabled: true, headerShown: false, }, @@ -1158,8 +1165,8 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig return usesWorkspaceFlowScreens ? { presentation: "card" } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.92], + presentation: "formSheet", + sheetAllowedDetents: [1], sheetGrabberVisible: true, }; }} diff --git a/apps/mobile/src/components/ui/AppSymbol.tsx b/apps/mobile/src/components/ui/AppSymbol.tsx index 4389a01122de8..8f5ad9ca5e890 100644 --- a/apps/mobile/src/components/ui/AppSymbol.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.tsx @@ -9,6 +9,7 @@ import IconAlertCircle from "@tabler/icons-react-native/IconAlertCircle"; import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; import IconApps from "@tabler/icons-react-native/IconApps"; import IconArchive from "@tabler/icons-react-native/IconArchive"; +import IconCalendarClock from "@tabler/icons-react-native/IconCalendarClock"; import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; import IconArrowDown from "@tabler/icons-react-native/IconArrowDown"; import IconArrowForwardUp from "@tabler/icons-react-native/IconArrowForwardUp"; @@ -192,6 +193,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "circle.dashed": IconCircleDashed, "circle.inset.filled": IconCircleDot, "circle.dashed.inset.filled": IconCircleDot, + "calendar.badge.clock": IconCalendarClock, clock: IconClock, timer: IconClock, ticket: IconTicket, diff --git a/apps/mobile/src/components/ui/Sheet.tsx b/apps/mobile/src/components/ui/Sheet.tsx index 21ee41a5c5569..56994803b4ab8 100644 --- a/apps/mobile/src/components/ui/Sheet.tsx +++ b/apps/mobile/src/components/ui/Sheet.tsx @@ -4,6 +4,7 @@ import { FormSheet, ScreenStack } from "react-native-screens"; import { useResolveClassNames } from "uniwind"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; +import { formSheetDetents } from "../../native/sheet-surface"; import { NativeHeader, NativeScreen } from "../../native/ThemedScreenStack"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -21,8 +22,10 @@ export function Sheet(props: SheetProps) { return ( void; }) { const insets = useSafeAreaInsets(); + // NativeColumnContent already insets the sides, so only a book fold moves the reveal handle. + const handleBounds = deriveBottomControlInsets(useNativeColumnLayoutMetrics()); const { themeVariables } = useAppearancePreferences(); const focused = useIsFocused(); const [foreground, setForeground] = useState(AppState.currentState !== "background"); @@ -318,7 +322,10 @@ function DevicePreviewScreen({ accessibilityHint="Shaking the phone also shows them" hitSlop={12} className="absolute h-6 w-16 items-center justify-center self-center" - style={{ top: Math.max(insets.top - 18, 4) }} + style={{ + top: Math.max(insets.top - 18, 4), + transform: [{ translateX: (handleBounds.left - handleBounds.right) / 2 }], + }} onPress={() => setControlsVisible(true)} > diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 901024a27827a..5f3deb6089c4b 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -21,6 +21,7 @@ import { useEnvironmentQuery } from "../../state/query"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { FileTreeBrowser } from "./FileTreeBrowser"; +import { NativeInspectorContent } from "../../native/NativeInspectorContent"; import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; @@ -120,7 +121,7 @@ export function ThreadFileNavigatorPane(props: { scrollEdgeEffects={headerScrollEdgeEffects} style={{ backgroundColor: sheetColor, flex: 1 }} > - {fileTree} + {fileTree} >(); - const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); - const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); - const deviceType = useDeviceType(); - const primaryColumn = use(NativePrimaryColumnContext); - const sidebarHeader = - Platform.OS === "ios" && - primaryColumn !== null && - (deviceType === "pad" || !usesNativeMailSearchToolbar); - const showsCompactCompose = deviceType === "pad" && !layout.usesSplitView; - const searchBarRef = useRef(null); - const focusAfterReveal = useRef(false); - useEffect( - () => - navigation.addListener("transitionEnd", (event) => { - if (focusAfterReveal.current && !event.data.closing) { - focusAfterReveal.current = false; - searchBarRef.current?.focus(); - } - }), - [navigation], - ); - const iconColor = useUniwindTheme()["--color-icon"]; - const hasCustomListOptions = - props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; - const focusSearch = useCallback(() => { - if (primaryColumn && !panes.primarySidebarVisible) { - focusAfterReveal.current = true; - togglePrimarySidebar(); - return true; - } - searchBarRef.current?.focus(); - return searchBarRef.current !== null; - }, [primaryColumn, panes.primarySidebarVisible, togglePrimarySidebar]); - useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu(props); - - if (props.usesWorkspaceToolbar) { - return deviceType === "duo-unfolded" ? ( - - ) : null; - } - - return ( - <> - - sidebarHeader - ? [ - ...createSidebarHeaderItems({ - filterIcon: hasCustomListOptions - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle", - filterMenu, - onOpenSettings: props.onOpenSettings, - }), - ...(showsCompactCompose - ? [ - withNativeGlassHeaderItem({ - accessibilityLabel: "New task", - axisBehavior: "horizontalOnly", - icon: { name: "square.and.pencil", type: "sfSymbol" } as const, - identifier: "home-new-task", - label: "", - onPress: props.onStartNewTask, - type: "button", - }), - ] - : []), - ] - : [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Open settings", - icon: { name: "ellipsis", type: "sfSymbol" } as const, - identifier: "home-settings", - label: "", - onPress: props.onOpenSettings, - type: "button", - }), - ], - // The keys below are set per-branch (not `undefined`) so a later - // reapply cannot clobber options owned by Toolbar. - ...(sidebarHeader - ? { - headerSearchBarOptions: { - ref: searchBarRef, - autoCapitalize: "none" as const, - hideNavigationBar: false, - hideWhenScrolling: false, - obscureBackground: false, - placement: "stacked" as const, - allowToolbarIntegration: false, - placeholder: "Search", - onCancelButtonPress: () => props.onSearchQueryChange(""), - onChangeText: (event) => props.onSearchQueryChange(event.nativeEvent.text), - }, - unstable_headerToolbarItems: () => [], - } - : usesNativeMailSearchToolbar - ? { - headerSearchBarOptions: { - ref: searchBarRef, - autoCapitalize: "none" as const, - onCancelButtonPress: () => props.onSearchQueryChange(""), - }, - unstable_headerToolbarItems: () => [ - createNativeMailSearchToolbarItem({ - composeButtonId: "home-new-task", - composeSystemImageName: "square.and.pencil", - filterMenu, - filterButtonId: "home-filter", - filterSystemImageName: hasCustomListOptions - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease", - onComposePress: props.onStartNewTask, - onSearchTextChange: props.onSearchQueryChange, - placeholder: "Search", - searchTextChangeId: "home-search-text", - showsSearchDismissButton: true, - }), - ], - } - : { - headerSearchBarOptions: { - ref: searchBarRef, - autoCapitalize: "none" as const, - hideNavigationBar: false, - placeholder: "Search", - onCancelButtonPress: () => { - props.onSearchQueryChange(""); - }, - onChangeText: (event) => { - props.onSearchQueryChange(event.nativeEvent.text); - }, - }, - }), - }} - /> - - {sidebarHeader || usesNativeMailSearchToolbar ? null : ( - - - - Environment - props.onEnvironmentChange(null)} - subtitle="Show threads from every environment" - > - All environments - - {props.environments.map((environment) => ( - props.onEnvironmentChange(environment.environmentId)} - > - {environment.label} - - ))} - - - {props.projects.length > 0 ? ( - - Project - props.onProjectChange(null)} - subtitle="Show threads from every project" - > - All projects - - {props.projects.map((project) => ( - props.onProjectChange(project.key)} - > - {project.label} - - ))} - - ) : null} - - - - - )} - - ); +/** The iOS thread list lives in the workspace, whose WorkspaceToolbar owns its header and search. */ +export function HomeHeader(_props: HomeHeaderProps) { + return null; } diff --git a/apps/mobile/src/features/home/HomeHeader.types.ts b/apps/mobile/src/features/home/HomeHeader.types.ts index 32648ef843a3e..3c5f580f9eefb 100644 --- a/apps/mobile/src/features/home/HomeHeader.types.ts +++ b/apps/mobile/src/features/home/HomeHeader.types.ts @@ -7,7 +7,6 @@ import type { export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; export interface HomeHeaderProps { - readonly usesWorkspaceToolbar?: boolean; readonly environments: ReadonlyArray; readonly projects: ReadonlyArray; readonly searchQuery: string; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 8ab642eb44fd3..c0e2b8c8f5953 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -228,7 +228,6 @@ export function HomeRouteScreen() { /> )} ) { const insets = useSafeAreaInsets(); const primaryColumn = use(NativePrimaryColumnContext); + const restingOffset = useRestingTopOffset(insets.top, props); if (Platform.OS !== "ios") return ; // v5 needs the actual content scroll view registered with its screen controller. @@ -233,6 +235,7 @@ function HomeScrollView(props: ComponentProps) { > ) { // past its edge, activating scroll-edge protection before any scroll. automaticallyAdjustContentInsets={false} contentInsetAdjustmentBehavior="never" - contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }} /> ); @@ -604,6 +606,11 @@ export function HomeScreen(props: HomeScreenProps) { showTrailingDivider={item.showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} onDeletePendingTask={props.onDeletePendingTask} + fullSwipeWidth={fullSwipeWidth} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + simultaneousSwipeGesture={homeScrollGesture} + activationKey={item.key} /> ); } diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 3fe3c2054e91b..e7b6a5b7bd033 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -62,6 +62,7 @@ interface ThreadSwipeAction { readonly accessibilityLabel: string; readonly icon: ComponentProps["name"]; readonly label: string; + readonly destructive?: boolean; readonly menu?: { readonly actions: MenuAction[]; readonly onPressAction: NonNullable["onPressAction"]>; @@ -748,7 +749,7 @@ export function ThreadSwipeActions(props: { void; @@ -37,10 +43,15 @@ export function usePendingTaskListActions(): { text: "Discard", style: "destructive", onPress: () => { - clearComposerDraftContent(pendingTask.draftKey, { - clearModelSelection: true, - clearWorkspaceSelection: true, - }); + void withThreadDismissal( + pendingTask.key, + async () => + clearComposerDraftContent(pendingTask.draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + }), + () => true, + ); }, }, ]); @@ -55,12 +66,28 @@ export function usePendingTaskListActions(): { text: "Delete", style: "destructive", onPress: () => { - // Release the edit lock only after removal succeeds, and only if - // it is held for THIS task — clearing it up front (or for another - // task) would let the drain deliver a mid-edit payload. - void removeThreadOutboxMessage(pendingTask.message) - .then(() => releaseEditingQueuedMessage(pendingTask.message.messageId)) + // The row animates out before removal runs, so hold delivery from + // confirmation on: a reconnect must not send a task being deleted. + // The hold is shared with the editor, which could release it + // mid-animation, so a task open in the editor is removed at once + // and keeps its hold unless removal succeeds. + const messageId = pendingTask.message.messageId; + const heldByEditor = appAtomRegistry.get(editingQueuedMessageIdsAtom)[messageId]; + holdEditingQueuedMessage(messageId); + const releaseUnlessEditing = () => { + if (!heldByEditor) releaseEditingQueuedMessage(messageId); + }; + const remove = () => removeThreadOutboxMessage(pendingTask.message); + void ( + heldByEditor + ? remove() + : withThreadDismissal(pendingTask.key, remove, (removed) => removed) + ) + .then((removed) => + removed ? releaseEditingQueuedMessage(messageId) : releaseUnlessEditing(), + ) .catch((error) => { + releaseUnlessEditing(); Dialog.alert( "Could not delete pending task", error instanceof Error ? error.message : "The pending task could not be removed.", diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 837887a73d6ed..0f18f24923cc4 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -456,10 +456,15 @@ export function useThreadListActions(): { // Pinning clears snooze, so older Settled or Snoozed undos would fight this pin. ThreadUndo.invalidate("settle", scopedThreadKey(thread.environmentId, thread.id)); ThreadUndo.invalidate("snooze", scopedThreadKey(thread.environmentId, thread.id)); - const result = await pinMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, - }); + const result = await withThreadDismissal( + scopedThreadKey(thread.environmentId, thread.id), + () => + pinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { const error = Cause.squash(result.cause); Dialog.alert( @@ -484,10 +489,15 @@ export function useThreadListActions(): { selectionHaptic(); const plan = ThreadUndo.planThreadUndo("unpin", thread); const claim = ThreadUndo.begin("pin", scopedThreadKey(thread.environmentId, thread.id)); - const result = await unpinMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + const result = await withThreadDismissal( + scopedThreadKey(thread.environmentId, thread.id), + () => + unpinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { claim.finish(); const error = Cause.squash(result.cause); diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index d264c0765bcf5..3c3fd349c2010 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -22,7 +22,9 @@ import { RowPressable } from "../../components/ui/RowPressable"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { cn } from "../../lib/cn"; +import { deriveBottomControlInsets } from "../../lib/reserved-regions"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useProjects, useThreadShell, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; @@ -156,6 +158,9 @@ export function CommandPalette(props: { const inputRef = useRef(null); const listRef = useRef>(null); const { width, height } = useWindowDimensions(); + // The palette floats above every column, so it keeps itself inside the window's side insets + // and to one side of a book fold. + const bounds = deriveBottomControlInsets(useNativeLayoutMetrics()); const searchEnvironmentIds = useMemo( () => environments @@ -423,7 +428,8 @@ export function CommandPalette(props: { void; readonly onAddConnection?: () => void; }) { + const pullRequests = props.tab === "pullRequests"; return ( - Select a thread + + {pullRequests ? "Select a pull request" : "Select a thread"} + - {Platform.OS === "android" - ? "Choose a thread from the sidebar or start a new thread." - : "Choose a thread from the sidebar or start a new task."} + {pullRequests + ? "Choose a pull request from the sidebar to review it." + : Platform.OS === "android" + ? "Choose a thread from the sidebar or start a new thread." + : "Choose a thread from the sidebar or start a new task."} - {props.onStartNewTask ? ( + {props.onStartNewTask && !pullRequests ? ( Platform.OS === "android" ? ( ) : ( diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index c4c0946fffd02..5acc33b87035c 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -14,7 +14,6 @@ import { } from "@react-navigation/native"; import { NativeStackView, - type NativeStackHeaderItemMenu, type NativeStackNavigationEventMap, type NativeStackNavigationOptions, type NativeStackNavigatorProps, @@ -25,9 +24,10 @@ import { View } from "react-native"; import { Split, type SplitHostCommands } from "react-native-screens"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { WorkspaceTabsProvider } from "../workspace/WorkspaceTabsContext"; +import { WorkspaceTabsProvider, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { WorkspaceNativeTabs } from "../workspace/WorkspaceNativeTabs.ios"; -import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; +import { isDuoDevice } from "../../lib/device-type"; +import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail"; @@ -60,14 +60,19 @@ type ViewProps = ComponentProps; type Descriptor = ViewProps["descriptors"][string]; type Route = ViewProps["state"]["routes"][number]; +const isPullRequestRoute = (route: Route) => + route.name === "PullRequest" || + route.name === "PullRequestFile" || + route.name === "PullRequestFiles"; + function ColumnScreen(props: { readonly descriptor: Descriptor; readonly primary?: boolean; readonly selectedThreadKey?: string | null; readonly selectedPullRequestKey?: string | null; - readonly sidebarFilter?: NativeStackHeaderItemMenu; readonly onDismiss: (routeKey: string) => void; readonly onAppearance: (type: "willAppear" | "didAppear" | "willDisappear", key: string) => void; + readonly onCompose?: () => void; readonly navigation: ViewProps["navigation"]; }) { const { descriptor } = props; @@ -109,8 +114,8 @@ function ColumnScreen(props: { props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + // The Duo keeps compose in the detail column's bar, which is its vertical bar in landscape + // and folded, so it sits with the column's other actions whichever screen is shown. Pull + // requests leave it out, including one opened from a thread; their bar is already full of + // review actions. + const detailCompose = + isDuoDevice(deviceType) && selectedTab !== "pullRequests" ? openNewTask : undefined; const inspector = use(NativeWorkspaceInspectorContext); const hostRef = useRef(null); const activeDetailKey = props.detail.at(-1)?.key; @@ -139,12 +153,7 @@ function WorkspaceColumns( const collapsed = useRef(!layout.usesSplitView); const collapsedColumn = useRef({ visible: compactColumn, leaving: null }); const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; - const pullRequestParams = props.detail.findLast( - (route) => - route.name === "PullRequest" || - route.name === "PullRequestFile" || - route.name === "PullRequestFiles", - )?.params; + const pullRequestParams = props.detail.findLast(isPullRequestRoute)?.params; const selectedPullRequestKey = pullRequestParams && "environmentId" in pullRequestParams && @@ -199,7 +208,6 @@ function WorkspaceColumns( const primary = props.descriptors[props.primary.key]; if (!primary) return null; - const sidebarFilter = (primary.options as AppNativeStackNavigationOptions).nativeSidebarFilter; return ( - props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }) + tab={selectedTab} + onStartNewTask={ + // The detail bar or a visible sidebar already carries compose. + detailCompose || panes.primarySidebarVisible ? undefined : openNewTask } /> @@ -282,8 +296,8 @@ function WorkspaceColumns( descriptor={descriptor} onDismiss={handleNativeDismiss} onAppearance={followCollapsedSplit} + onCompose={isPullRequestRoute(route) ? undefined : detailCompose} navigation={props.navigation} - sidebarFilter={sidebarFilter} /> ) : null; })} @@ -291,7 +305,9 @@ function WorkspaceColumns( diff --git a/apps/mobile/src/features/phasesidebar/PhaseSidebarPane.tsx b/apps/mobile/src/features/phasesidebar/PhaseSidebarPane.tsx index 4ab1ef70a0336..30963737b3862 100644 --- a/apps/mobile/src/features/phasesidebar/PhaseSidebarPane.tsx +++ b/apps/mobile/src/features/phasesidebar/PhaseSidebarPane.tsx @@ -33,7 +33,8 @@ import { phaseSidebarFiltersActive } from "@t3tools/client-runtime/state/phase-s import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { isReservedThreadCustomGroup, type EnvironmentId } from "@t3tools/contracts"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; -import { useCallback, useMemo, useState, type ComponentProps } from "react"; +import { useCallback, useMemo, useRef, useState, type ComponentProps } from "react"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { Alert, FlatList, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; @@ -137,6 +138,19 @@ export function PhaseSidebarPane(props: { const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const { savedConnectionsById } = useSavedRemoteConnections(); const { machineByEnvironmentId } = useAtomValue(threadListEnvironmentsAtom); + // One draft row stays swiped open at a time, as in the thread sidebar. + const openSwipeableRef = useRef(null); + const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current !== methods) { + openSwipeableRef.current?.close(); + openSwipeableRef.current = methods; + } + }, []); + const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current === methods) { + openSwipeableRef.current = null; + } + }, []); const renderDraftRow = useCallback( (draft: PendingDraftTask) => ( ); diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 10cb2d7a37202..a513fd424ced1 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -56,6 +56,7 @@ import { View, } from "react-native"; import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; +import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; @@ -117,13 +118,14 @@ const EMPTY_IDS: readonly string[] = []; function PullRequestTabScrollView(props: ScrollViewProps) { const insets = useSafeAreaInsets(); + const restingOffset = useRestingTopOffset(insets.top, props); return ( ); @@ -299,6 +301,18 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps>>({}); + // AnimatedLegendList re-attaches its handle and sets state whenever its ref changes, + // so an inline ref in the tab map loops the screen into "Maximum update depth exceeded". + const [listRefs] = useState(() => { + const listRef = (tab: typeof section) => (list: LegendListRef | null) => { + scrollViews.current[tab] = list; + }; + return { + overview: listRef("overview"), + guide: listRef("guide"), + activity: listRef("activity"), + }; + }); const selectSection = (next: typeof section) => { if (next === section) return; scrollOffsets.current[section] = currentScrollOffset.current; @@ -545,10 +559,10 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { - scrollViews.current[tab] = list; - }} - initialScrollOffset={-insets.top} + ref={listRefs[tab]} + // The scroll view owns its resting offset. LegendList keeps the first + // initialScrollOffset it gets, and the first render has the window's top + // inset (0 on the folded iPhone Duo), not the header's. onScroll={(event) => { const offset = event.nativeEvent.contentOffset.y; scrollOffsets.current[tab] = offset; diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index e0b97485551c2..ea287c16cf8a5 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -21,6 +21,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; +import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { useWorkspaceState } from "../../state/workspace"; import { useProjects } from "../../state/entities"; @@ -46,13 +47,14 @@ import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSe function PullRequestScrollView(props: ScrollViewProps) { const insets = useSafeAreaInsets(); + const restingOffset = useRestingTopOffset(insets.top, props); return ( ); diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 2e79297c865e4..602b736757d00 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -30,6 +30,7 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; +import { NativeInspectorContent } from "../../native/NativeInspectorContent"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; @@ -40,7 +41,6 @@ import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -314,7 +314,6 @@ function ReviewFileNavigator({ onSelectFile, ref, }: ReviewFileNavigatorProps) { - const insets = useSafeAreaInsets(); const theme = useUniwindTheme(); const sheetColor = theme["--color-sheet"]; const foregroundColor = theme["--color-foreground"]; @@ -366,15 +365,8 @@ function ReviewFileNavigator({ data={files} extraData={selectedFileId} keyExtractor={(file) => file.id} - contentContainerStyle={{ - paddingHorizontal: 8, - paddingBottom: 8, - - paddingTop: Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT + 8 : 8, - }} - scrollIndicatorInsets={ - Platform.OS === "ios" ? { top: insets.top + IOS_NAV_BAR_HEIGHT } : undefined - } + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + contentContainerStyle={{ paddingHorizontal: 8, paddingVertical: 8 }} renderItem={renderFile} /> ); @@ -391,7 +383,7 @@ function ReviewFileNavigator({ scrollEdgeEffects={headerScrollEdgeEffects} style={{ backgroundColor: sheetColor, flex: 1 }} > - {fileList} + {fileList} [ withNativeGlassHeaderItem({ - axisBehavior: "horizontalOnly", accessibilityLabel: "Filter settings environments and projects", icon: { name: filterIcon, type: "sfSymbol" }, label: "", @@ -121,7 +120,6 @@ export function SettingsEnvironmentFilterHeader(props: { ...(closeSettings ? [ withNativeGlassHeaderItem({ - axisBehavior: "horizontalOnly", accessibilityLabel: "Close settings", icon: { name: "xmark", type: "sfSymbol" }, identifier: "settings-close", diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 28cf1ec0fa790..453b6829bd621 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -21,7 +21,8 @@ import { usePreventRemove, type NavigationAction, } from "@react-navigation/native"; -import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; +import { HeaderHeightContext } from "@react-navigation/elements"; +import { useContext, useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; import { Platform, Pressable, ScrollView, View } from "react-native"; import { KeyboardController, @@ -214,6 +215,9 @@ export function NewTaskDraftScreen(props: { reserveShare, } = useIncomingShare(); const insets = useSafeAreaInsets(); + // The iOS glass header floats over the hero, and its height differs between an + // iPhone sheet and an iPad column that also carries the status bar. + const navigationHeaderHeight = useContext(HeaderHeightContext) ?? insets.top + 56; const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = Math.max(insets.bottom, 10); const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); @@ -1550,7 +1554,10 @@ export function NewTaskDraftScreen(props: { alwaysBounceVertical={isKeyboardVisible} className="flex-1" contentInsetAdjustmentBehavior="never" - contentContainerClassName="grow items-center pb-[236px] pt-12 ios:pt-[72px]" + contentContainerClassName="grow items-center pb-[236px] pt-12" + contentContainerStyle={ + Platform.OS === "ios" ? { paddingTop: navigationHeaderHeight + 16 } : undefined + } keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index 3037ee422bdd3..b5a9d4ed27eb2 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -4,7 +4,12 @@ import type { ProviderApprovalOption, RuntimeRequestId, } from "@t3tools/contracts"; -import { View } from "react-native"; +import { + FULL_ACCESS_OVERRIDE_QUESTION, + fullAccessOverrideExplanation, +} from "@t3tools/client-runtime/state/thread-requests"; +import { useState } from "react"; +import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import type { PendingApproval } from "../../lib/threadActivity"; @@ -31,6 +36,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { const warning = options.find((option) => option.warning)?.warning; const canRespond = props.approval.responseCapability === "live"; const disabled = !canRespond || props.respondingApprovalId === props.approval.requestId; + const [explanationOpen, setExplanationOpen] = useState(false); return ( @@ -44,6 +50,27 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { {props.approval.detail} ) : null} + {props.approval.reason ? ( + + {props.approval.reason} + + ) : null} + {props.approval.fullAccessOverride ? ( + setExplanationOpen((open) => !open)} + > + + {FULL_ACCESS_OVERRIDE_QUESTION} + + {explanationOpen ? ( + + {fullAccessOverrideExplanation(props.approval.fullAccessOverride)} + + ) : null} + + ) : null} {!canRespond ? ( The provider process for this request is no longer available. Interrupt or restart the run diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index 77ecc530b90b7..447254e2bbf28 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -9,7 +9,7 @@ import { Animated, FlatList, Modal, Pressable, View } from "react-native"; import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { threadDragGapOffset } from "./threadDragGap"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; @@ -149,7 +149,6 @@ function DragHandle(props: { } export function ThreadArrangementSheet(props: { onClose: () => void }) { - const insets = useSafeAreaInsets(); const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom); const configs = useAtomValue(environmentServerConfigsAtom); const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom); @@ -373,184 +372,187 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { onRequestClose={props.onClose} > - - - - Drag to reorder, pin, or settle. Changes save when you drop. - - { - geometry.current.height = event.nativeEvent.layout.height; - }} - className="flex-1" - style={{ overflow: "hidden" }} - > - row.key} - scrollEnabled={visiblePreview === null} - removeClippedSubviews={false} - onScroll={(event) => { - geometry.current.offset = event.nativeEvent.contentOffset.y; - }} - scrollEventThrottle={16} - getItemLayout={(_, index) => ({ - length: rows[index]!.height, - offset: rows[index]!.offset, - index, - })} - renderItem={({ item }) => { - const thread = item.thread; - const planner = - item.section === "pinned" || item.section === "active" - ? planners[item.section] - : null; - const capabilities = - thread && configs.get(thread.environmentId)?.environment.capabilities; - const sectionActions = thread - ? (["pinned", "active", "settled"] as const).flatMap<{ - name: "pinned" | "active" | "settled"; - label: string; - }>((section) => { - if (section === item.section) return []; - const label = threadDragAction(item.section, section); - if (!label) return []; - if (section === "settled") - return capabilities?.threadSettlement ? [{ name: section, label }] : []; - if ( - ((section === "pinned" || thread.pinnedAt != null) && - !capabilities?.threadPinning) || - (section === "active" && - item.section === "settled" && - !capabilities?.threadSettlement) || - (section === "active" && - item.section === "snoozed" && - !capabilities?.threadSnooze) - ) - return []; - return planners[section](item.key, { - section, - targetId: null, - placement: "before", - }) - ? [{ name: section, label }] - : []; - }) - : []; - return ( - + + + + + Drag to reorder, pin, or settle. Changes save when you drop. + + { + geometry.current.height = event.nativeEvent.layout.height; + }} + className="flex-1" + style={{ overflow: "hidden" }} + > + row.key} + scrollEnabled={visiblePreview === null} + removeClippedSubviews={false} + onScroll={(event) => { + geometry.current.offset = event.nativeEvent.contentOffset.y; + }} + scrollEventThrottle={16} + getItemLayout={(_, index) => ({ + length: rows[index]!.height, + offset: rows[index]!.offset, + index, + })} + renderItem={({ item }) => { + const thread = item.thread; + const planner = + item.section === "pinned" || item.section === "active" + ? planners[item.section] + : null; + const capabilities = + thread && configs.get(thread.environmentId)?.environment.capabilities; + const sectionActions = thread + ? (["pinned", "active", "settled"] as const).flatMap<{ + name: "pinned" | "active" | "settled"; + label: string; + }>((section) => { + if (section === item.section) return []; + const label = threadDragAction(item.section, section); + if (!label) return []; + if (section === "settled") + return capabilities?.threadSettlement ? [{ name: section, label }] : []; + if ( + ((section === "pinned" || thread.pinnedAt != null) && + !capabilities?.threadPinning) || + (section === "active" && + item.section === "settled" && + !capabilities?.threadSettlement) || + (section === "active" && + item.section === "snoozed" && + !capabilities?.threadSnooze) ) - : 0 - } - > - {thread ? ( - <> - - {thread.title} - - { - void moveThread(thread, { - section, - targetId: null, - placement: "before", - }); - }} - canMoveUp={planner?.(item.key, "up") != null} - canMoveDown={planner?.(item.key, "down") != null} - onStep={(direction) => { - void moveThread(thread, direction); - }} - onStart={() => start(item)} - onMove={update} - onEnd={(cancelled) => { - const current = drag.current; - if (cancelled || !current?.destination) { - stop(); - return; - } - if (frame.current !== null) cancelAnimationFrame(frame.current); - frame.current = null; - drag.current = null; - void moveThread(current.thread, current.destination).finally(stop); - }} - /> - - ) : ( - { - const section = item.section; - if (section === "snoozed" || section === "settled") - setExpanded((value) => ({ ...value, [section]: !value[section] })); - }} + return []; + return planners[section](item.key, { + section, + targetId: null, + placement: "before", + }) + ? [{ name: section, label }] + : []; + }) + : []; + return ( + - - {item.section[0]!.toUpperCase() + item.section.slice(1)} ( - {sections[item.section].length}) - - - )} - - ); - }} - /> - {visiblePreview ? ( - - - {visiblePreview.thread.title} - - {visiblePreview.destination?.section ? ( - - {threadDragAction( - visiblePreview.sourceSection, - visiblePreview.destination.section, - )} - + {thread ? ( + <> + + {thread.title} + + { + void moveThread(thread, { + section, + targetId: null, + placement: "before", + }); + }} + canMoveUp={planner?.(item.key, "up") != null} + canMoveDown={planner?.(item.key, "down") != null} + onStep={(direction) => { + void moveThread(thread, direction); + }} + onStart={() => start(item)} + onMove={update} + onEnd={(cancelled) => { + const current = drag.current; + if (cancelled || !current?.destination) { + stop(); + return; + } + if (frame.current !== null) cancelAnimationFrame(frame.current); + frame.current = null; + drag.current = null; + void moveThread(current.thread, current.destination).finally(stop); + }} + /> + + ) : ( + { + const section = item.section; + if (section === "snoozed" || section === "settled") + setExpanded((value) => ({ ...value, [section]: !value[section] })); + }} + > + + {item.section[0]!.toUpperCase() + item.section.slice(1)} ( + {sections[item.section].length}) + + + )} + + ); + }} + /> + {visiblePreview ? ( + + + {visiblePreview.thread.title} + + {visiblePreview.destination?.section ? ( + + {threadDragAction( + visiblePreview.sourceSection, + visiblePreview.destination.section, + )} + + ) : null} + ) : null} - - ) : null} + + - + ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 75fbc4d5f5e69..846b0f4dc4091 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -360,6 +360,9 @@ export function ComposerSurface(props: { ); } +/** Space below the composer pill, inside the bottom inset it is given. */ +export const THREAD_COMPOSER_BOTTOM_GUTTER = 6; + export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps) { const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId)); const { themeVariables: materialTheme } = useAppearancePreferences(); @@ -839,7 +842,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps className="px-[12px]" style={{ paddingTop: usesCard ? 8 : 6, - paddingBottom: (props.bottomInset ?? 0) + (usesCard ? 8 : 6), + paddingBottom: (props.bottomInset ?? 0) + (usesCard ? 8 : THREAD_COMPOSER_BOTTOM_GUTTER), backgroundColor: Platform.OS === "android" ? themeColorWithAlpha(composerPanel, 1) : undefined, }} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index cf483a3ac1aa4..3e08bd1528a2e 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -104,7 +104,10 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; -import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; +import { + useDuoBottomBarInset, + useNativeColumnLayoutMetrics, +} from "../../native/native-layout-metrics"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; @@ -164,6 +167,7 @@ import { COMPOSER_EXPANDED_CHROME, COMPOSER_LAYOUT_TRANSITION, COMPOSER_TRANSITION_DURATION_MS, + THREAD_COMPOSER_BOTTOM_GUTTER, ThreadComposer, } from "./ThreadComposer"; import { ComposerPopoverHost } from "./ComposerPopoverHost"; @@ -545,10 +549,13 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const [endFollowEnabled, setEndFollowEnabled] = useState(true); // Android Back hides the keyboard without blurring. Keep its safe-area padding // fixed through the animation and cancel it with matching composer/list offsets. + const bottomBarInset = useDuoBottomBarInset(); const composerBottomInset = Platform.OS === "ios" && (composerExpanded || composerFocused) ? 0 - : Math.max(insets.bottom, 12); + : bottomBarInset !== undefined + ? bottomBarInset - THREAD_COMPOSER_BOTTOM_GUTTER + : Math.max(insets.bottom, 12); const composerKeyboardOffset = Platform.OS === "android" ? composerBottomInset : 0; const contentPresentationKind = props.contentPresentation.kind; const realThreadSyncLabel = (() => { diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 9263e7b90e5bf..b2335073681c2 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -40,6 +40,7 @@ import { type MessageId, type OrchestrationV2ProjectedTurnItem, type RunId, + type ScheduledTaskId, } from "@t3tools/contracts"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; // T3-CUSTOM(expbkt3): anchored plan feedback. @@ -91,6 +92,7 @@ import { useCallback, useContext, useEffect, + useEffectEvent, useLayoutEffect, useMemo, useRef, @@ -210,7 +212,11 @@ import { import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedLiveFollow, + resolveThreadFeedReflowAnchor, + resolveThreadFeedReflowOffset, + resolveThreadFeedReflowScroll, type ThreadFeedLiveFollowEvent, + type ThreadFeedReflowAnchor, type ThreadWorkGroupScrollPosition, } from "./thread-feed-live-follow"; import { @@ -245,6 +251,8 @@ import { } from "@t3tools/client-runtime/workspace-basename-lookup"; import { useLiveThreadLinkLabels } from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; import * as Option from "effect/Option"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -290,6 +298,14 @@ function formatMessageTime(input: string): string { } const TURN_FOLD_HEIGHT = 42; +const FEED_PADDING_TOP = 12; +// Rows re-measure within a commit or two of a width change. LegendList also defers imperative +// scrolls for 100ms after it compensates a size change, and a deferred restore could land +// under a drag that starts meanwhile. +const FEED_REFLOW_SETTLE_MS = 200; +// A fold resizes the feed in steps about 200ms apart. Holding the place taken at the first step +// across all of them keeps a passing moment off the end from re-anchoring the rest to a row. +const FEED_REFLOW_HOLD_MS = 600; const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5; const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5; const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( @@ -992,6 +1008,125 @@ function AgentMessageAttribution(props: { ); } +/** Automation prompts are long, repeated runbooks; a folded bubble keeps them out of the way. */ +function ThreadAutomationMessageRow(props: { + readonly environmentId: EnvironmentId; + readonly text: string; + readonly context?: OrchestrationMessageContext; + readonly scheduledTaskId: ScheduledTaskId | undefined; + readonly timestampLabel: string; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly bubbleColor: ColorValue; + readonly bubbleMaxWidth: number | "85%"; + readonly contentMaxWidth: number; + readonly iconSubtleColor: ColorValue; + readonly markdownStyles: MarkdownStyleSet; + readonly reviewCommentColors: ReviewCommentColors; + readonly skills?: ReadonlyArray; + readonly linkHandlers: MarkdownLinkHandlers; + readonly renderImage: MarkdownImageRenderer; +}) { + const navigation = useNavigation(); + const tasks = useEnvironmentQuery( + props.scheduledTaskId + ? serverEnvironment.scheduledTasksLive({ environmentId: props.environmentId, input: {} }) + : null, + ); + const taskTitle = tasks.data?.tasks.find((task) => task.id === props.scheduledTaskId)?.title; + const summary = taskTitle ?? props.text.trim().split(/\r?\n/, 1)[0] ?? ""; + + return ( + + + { + void Haptics.selectionAsync(); + props.onToggle(); + }} + > + + + Automation + + + {summary} + + + + {props.expanded ? ( + + + + + + ) : null} + + {props.expanded ? ( + + {props.scheduledTaskId ? ( + + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsScheduledTasks" }, + }) + } + > + + Open automation + + + ) : null} + + {props.timestampLabel} + + + + ) : null} + + ); +} + function renderFeedEntry( info: { item: PendingThreadFeedEntry; index: number }, props: Pick< @@ -1209,6 +1344,29 @@ function renderFeedEntry( /> ); } + if (isUser && presentation.attribution === "automation") { + const rowId = `automation:${message.id}`; + return ( + props.onToggleWorkRow(rowId, entry.id)} + bubbleColor={userBubbleColor} + bubbleMaxWidth={props.duoColumns ? "85%" : props.userBubbleMaxWidth} + contentMaxWidth={props.userBubbleMaxWidth - USER_BUBBLE_HORIZONTAL_PADDING * 2} + iconSubtleColor={iconSubtleColor} + markdownStyles={markdownStyles.user} + reviewCommentColors={props.reviewCommentColors} + skills={props.skills} + linkHandlers={props.markdownLinkHandlers} + renderImage={props.renderMarkdownImage} + /> + ); + } if (isUser) { const enterAnimated = isFreshTimestamp(message.createdAt); const intentBadge = resolveUserMessageIntentBadge(message.inputIntent); @@ -1230,11 +1388,7 @@ function renderFeedEntry( className="mb-5 items-end" {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} > - {presentation.attribution === "automation" ? ( - - Sent by automation - - ) : presentation.attribution === "t3code" ? ( + {presentation.attribution === "t3code" ? ( Sent by T3 Code @@ -1822,6 +1976,33 @@ function ThreadFeedPlaceholder(props: { ); } +// A hook's return value is frozen to the compiler, so ThreadFeed's later +// hooks can't drag this derivation into an uncached scope. +function useThreadFeedPresentation( + props: ThreadFeedProps, + expandedTurnIds: ReadonlySet, + expandedWorkGroups: Record, +) { + const [feedCache] = useState(createThreadFeedCache); + return appendPendingThreadMessages( + deriveThreadFeedPresentation( + props.feed, + props.latestRun, + expandedTurnIds, + new Set( + Object.entries(expandedWorkGroups) + .filter(([, expanded]) => expanded) + .map(([groupId]) => groupId), + ), + props.activeWorkStartedAt, + props.runlessWorkActive ?? false, + feedCache, + ), + props.feed, + props.queuedMessages, + ); +} + export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const { listRef, onEndFollowEnabledChange, onHeaderMaterialVisibilityChange } = props; const workflowOpening = useWorkflowOpening(props.environmentId, props.threadId); @@ -1847,7 +2028,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); - const [feedCache] = useState(createThreadFeedCache); const previousLatestTurnRef = useRef(props.latestRun); const userScrollSettleTimerRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); @@ -1893,6 +2073,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const transitionEndFollow = (event: ThreadFeedLiveFollowEvent) => { setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event)); }; + // A width change re-wraps every row at once, and LegendList re-measures them over the next + // commits. Its end threshold and MVCP only see the new geometry, so the reader's place is + // taken before any row lands: the end while following, otherwise the row under the header. + // On iOS, LegendList keeps that place in the same commit as every re-measure. Android's + // list moves only while its MVCP is on, which following turns off, so it restores after. + // undefined: no reflow. null: a reflow with nothing to restore. + const reflowAnchorRef = useRef(undefined); + const reflowAnchorsSizeChanges = Platform.OS === "ios"; + const [reflowFollowsEnd, setReflowFollowsEnd] = useState(false); + const [reflowSettleRevision, setReflowSettleRevision] = useState(0); + const listHeaderSizeRef = useRef(0); + const loadedListKeyRef = useRef(null); + const cancelReflow = () => { + if (reflowAnchorRef.current === undefined) return; + reflowAnchorRef.current = undefined; + setReflowFollowsEnd(false); + }; const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -2340,6 +2537,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const handleScrollBeginDrag = () => { clearUserScrollSettle(); userScrollSessionRef.current = true; + cancelReflow(); // Pause before the first scroll event. Otherwise a stream update can run // maintainScrollAtEnd between touch-down and the drag leaving its threshold. transitionEndFollow({ type: "user-scroll-begin" }); @@ -2379,47 +2577,25 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId); const workGroupScrollPositions = new Map(); - useEffect(() => { + // Only a new thread or a send resets following. Listed as dependencies, these handlers also ran + // whenever a parent passed a new follow callback, as a fold does: following re-armed under a + // reader and a width reflow lost its place mid-fold. + const resetEndFollow = useEffectEvent((_trigger: unknown) => { clearUserScrollSettle(); userScrollSessionRef.current = false; + cancelReflow(); transitionEndFollow({ type: "reset" }); - }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); + }); + useEffect(() => resetEndFollow(feedThreadKey), [feedThreadKey]); useEffect(() => { - if (props.submittedMessageId !== null) { - clearUserScrollSettle(); - userScrollSessionRef.current = false; - transitionEndFollow({ type: "reset" }); - } - }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]); - - const handleViewportLayout = (event: LayoutChangeEvent) => { - const nextWidth = Math.round(event.nativeEvent.layout.width); - const nextHeight = Math.round(event.nativeEvent.layout.height); - setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); - setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); - }; + if (props.submittedMessageId !== null) resetEndFollow(props.submittedMessageId); + }, [props.submittedMessageId]); useEffect(() => { reportHeaderMaterialVisibility(false); }, [feedThreadKey, reportHeaderMaterialVisibility]); - const presentedFeed = appendPendingThreadMessages( - deriveThreadFeedPresentation( - props.feed, - props.latestRun, - expandedTurnIds, - new Set( - Object.entries(expandedWorkGroups) - .filter(([, expanded]) => expanded) - .map(([groupId]) => groupId), - ), - props.activeWorkStartedAt, - props.runlessWorkActive ?? false, - feedCache, - ), - props.feed, - props.queuedMessages, - ); + const presentedFeed = useThreadFeedPresentation(props, expandedTurnIds, expandedWorkGroups); const setupAnchorIndex = presentedFeed.findIndex( (entry) => entry.type === "message" && entry.message.role === "user", ); @@ -2435,6 +2611,67 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } }, [listMountKey, props.contentInsetEndAdjustment, listRef]); + const reflowLayout = () => ({ + rowsTop: FEED_PADDING_TOP + listHeaderSizeRef.current, + headerInset: anchorTopInset, + }); + const beginReflow = () => { + // Until the list loads, its own initial end positioning owns the offset. + if (loadedListKeyRef.current !== listMountKey) return; + if (reflowAnchorRef.current === undefined) { + const listState = listRef.current?.getState(); + const anchor = + listState && !userScrollSessionRef.current + ? resolveThreadFeedReflowAnchor(listState, reflowLayout(), endFollowEnabledRef.current) + : null; + reflowAnchorRef.current = anchor; + if (anchor === "end" && !reflowAnchorsSizeChanges) { + // The list may already have judged the first new sizes against its own threshold, so + // pin the end once; the lifted threshold keeps it there as later rows re-measure. + setReflowFollowsEnd(true); + void listRef.current?.scrollToEnd({ animated: false }); + } + } + setReflowSettleRevision((revision) => revision + 1); + }; + const settleReflow = useEffectEvent(() => { + const anchor = reflowAnchorRef.current; + cancelReflow(); + const list = listRef.current; + if (reflowAnchorsSizeChanges || !anchor || anchor === "end" || !list) return; + if (userScrollSessionRef.current) return; + if (loadedListKeyRef.current !== listMountKey) return; + const target = resolveThreadFeedReflowScroll(anchor, list.getState(), reflowLayout()); + if (target) void list.scrollToIndex({ ...target, viewPosition: 0, animated: false }); + }); + useEffect(() => { + if (reflowSettleRevision === 0) return; + const timeout = setTimeout( + () => settleReflow(), + reflowAnchorsSizeChanges ? FEED_REFLOW_HOLD_MS : FEED_REFLOW_SETTLE_MS, + ); + return () => clearTimeout(timeout); + }, [reflowSettleRevision]); + + const holdReflowAnchor = () => { + const anchor = reflowAnchorRef.current; + if (anchor === "end") return Number.POSITIVE_INFINITY; + const list = listRef.current; + return anchor && list + ? resolveThreadFeedReflowOffset(anchor, list.getState(), reflowLayout()) + : undefined; + }; + + const handleViewportLayout = (event: LayoutChangeEvent) => { + const nextWidth = Math.round(event.nativeEvent.layout.width); + const nextHeight = Math.round(event.nativeEvent.layout.height); + // A native resize re-wraps the rows before JS hears of it. This event reaches JS ahead + // of the row measurements it caused, since layout events are sent parent first. + if (Math.abs(measuredViewportWidth - nextWidth) > 1) beginReflow(); + setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); + setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); + }; + const anchoredEndSpace = resolveChatListAnchoredEndSpace( presentedFeed, props.anchorMessageId, @@ -2517,6 +2754,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }; const suspendEndScrollMaintenanceForDisclosure = (anchorKey: string | null) => { + cancelReflow(); disclosureAnchorKeyRef.current = anchorKey; setDisclosureToggleSettling(true); }; @@ -2661,16 +2899,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { useLayoutEffect(() => { if (previousContentWidth.current === contentWidth) return; previousContentWidth.current = contentWidth; + // Rows re-rendered at this width are measured in the next commit, after this effect. + beginReflow(); if (hasHtmlRenders) listRef.current?.clearCaches({ mode: "sizes" }); - // A reflow can grow the visible rows past LegendList's near-end threshold in one batch, and - // maintainScrollAtEnd then stops following. Pin the end the feed was following once the new - // sizes are in. - if (!endFollowEnabledRef.current || !listRef.current?.getState().isAtEnd) return; - const frame = requestAnimationFrame(() => { - if (endFollowEnabledRef.current) void listRef.current?.scrollToEnd({ animated: false }); - }); - return () => cancelAnimationFrame(frame); - }, [contentWidth, hasHtmlRenders, listRef]); + }, [beginReflow, contentWidth, hasHtmlRenders, listRef]); const renderItem = (info: { item: PendingThreadFeedEntry; index: number }) => ( entry.id} getItemType={(entry) => - entry.type === "message" ? `message:${entry.message.role}` : entry.type + entry.type !== "message" + ? entry.type + : resolveUserMessagePresentation(entry.message).attribution === "automation" + ? "message:automation" + : `message:${entry.message.role}` } getFixedItemSize={getFixedItemSize} // Virtualized rows must move with their measurements. Native layout @@ -2854,6 +3094,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { estimatedItemSize={180} alignItemsAtEnd initialScrollAtEnd + onLoad={() => { + loadedListKeyRef.current = listMountKey; + }} + // Row positions start below the header; the reflow anchor converts between them. + onMetricsChange={(metrics) => { + listHeaderSizeRef.current = metrics.headerSize; + }} onScroll={handleScroll} onScrollBeginDrag={handleScrollBeginDrag} onScrollEndDrag={handleScrollEndDrag} @@ -2883,7 +3130,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ) : null } contentContainerStyle={{ - paddingTop: 12, + paddingTop: FEED_PADDING_TOP, paddingLeft: contentHorizontalPadding + contentLeftInset, paddingRight: contentHorizontalPadding + contentRightInset, }} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index ed466e628f7a3..465d527569475 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -603,6 +603,10 @@ function ThreadNavigationSidebarPane( showPendingDivider={item.showPendingDivider} onSelectPendingTask={props.onOpenPendingTask} onDeletePendingTask={confirmDeletePendingTask} + fullSwipeWidth={props.width - 20} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + simultaneousSwipeGesture={sidebarScrollGesture} /> ); } diff --git a/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx b/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx index c9075f851ee59..dcf70291e9b74 100644 --- a/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx @@ -2,15 +2,14 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native" import { LegendList } from "@legendapp/list/react-native"; import { useMemo } from "react"; import { Platform, View } from "react-native"; -import { ScreenStack } from "react-native-screens"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidSheetHeader } from "../../components/ui/AndroidScreenHeader"; import { AppText as Text } from "../../components/ui/AppText"; import { ScreenContent } from "../../components/ui/ScreenContent"; -import { NativeSheetContent } from "../../native/NativeSheetContent"; -import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; -import { NativeHeader, NativeScreen } from "../../native/ThemedScreenStack"; +import { SheetHeader } from "../../components/ui/SheetHeader"; +import { Toolbar } from "../../components/ui/Toolbar"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; @@ -21,7 +20,6 @@ import { } from "./LinkedPullRequestList"; import { useLinkedPullRequestActions } from "./useLinkedPullRequestActions"; -const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); const TITLE = "Linked pull requests"; export function ThreadPullRequestsSheet( @@ -92,47 +90,19 @@ export function ThreadPullRequestsSheet( ); if (Platform.OS === "ios") { - // A plain formSheet screen never renders a stack header, so it comes from - // a nested native stack inside the sheet (same shape as the git sheet). return ( - - - {content} - navigation.goBack()} /> + {actions.canLink ? ( + + - - + + ) : null} + {content} ); } diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index e5920b06ebe5e..11ac46da06d01 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -149,6 +149,8 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { void Haptics.selectionAsync(); if (action === "remove") { await cancel(buildCancelQueuedRunCommand({ ...target, runId })); + } else if (workflow?.canSendNow) { + await resume({ ...target, input: { threadId: target.threadId, runId } }); } else if (workflow?.activeRun && workflow.canPromoteToSteer) { await promote({ ...target, @@ -221,6 +223,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { const controls = resolveThreadQueueRowControls({ busy: busyRunId !== null || draggedRunId !== null, canPromoteToSteer: workflow?.canPromoteToSteer ?? false, + canSendNow: workflow?.canSendNow ?? false, canReorder: workflow?.canReorder ?? false, index, isEditing: editing?.runId === run.id, @@ -326,12 +329,12 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { accessibilityLabel={`Actions for queued message ${index + 1}`} shouldOpenOnLongPress actions={[ - ...(workflow?.canPromoteToSteer + ...(workflow?.canPromoteToSteer || workflow?.canSendNow ? [ { id: "steer", - title: "Steer now", - attributes: { disabled: !controls.canSteer }, + title: workflow?.canSendNow ? "Send now" : "Steer now", + attributes: { disabled: !controls.canSend }, image: Platform.OS === "ios" ? "arrow.turn.left.up" : "arrow_upward", }, ] @@ -385,16 +388,20 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { Editing ) : null} - {workflow?.canPromoteToSteer ? ( + {workflow?.canPromoteToSteer || workflow?.canSendNow ? ( void act(run.id, "steer")} className="h-8 shrink-0 justify-center rounded-full bg-primary px-3 active:opacity-70 disabled:opacity-40" > - Steer + {workflow?.canSendNow ? "Send now" : "Steer"} ) : null} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index eab14dc38ed0f..19ed0b1a53354 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -85,6 +85,7 @@ import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, } from "../layout/native-mail-search-toolbar"; +import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; import { ModelRow, ChoiceRow } from "./ThreadSettingsRows"; import { @@ -847,10 +848,8 @@ function ThreadSettingsOptionsItem(props: { .get(session.environmentId) ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null) : null; - const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); - const bottomToolbarInset = usesNativeMailSearchToolbar - ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET - : 0; + // Both iOS search layouts float a bottom toolbar over the list's last rows. + const bottomToolbarInset = Platform.OS === "ios" ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET : 0; return ( @@ -1211,11 +1210,17 @@ function useThreadSettingsPickerPresentation() { return value; } +// With a Duo vertical bar, filter and search join cancel and confirm in the rail. +function useThreadSettingsMailSearchToolbar() { + const metrics = useNativeLayoutMetrics(); + return useNativeMailSearchToolbar() && (metrics?.verticalBarEdge ?? "none") === "none"; +} + function ThreadSettingsModelsScreen() { const session = useThreadSettingsSession(); const presentation = useThreadSettingsPickerPresentation(); const navigation = useNavigation>(); - const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); + const usesNativeMailSearchToolbar = useThreadSettingsMailSearchToolbar(); const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; const commitAndClose = useEffectEvent(() => { if (!session.commitPendingModel()) return; @@ -1335,6 +1340,7 @@ function ThreadSettingsModelsScreen() { headerSearchBarOptions: Platform.OS === "ios" && !usesNativeMailSearchToolbar ? { + allowToolbarIntegration: true, autoCapitalize: "none", hideNavigationBar: false, obscureBackground: false, @@ -1378,7 +1384,6 @@ function ThreadSettingsModelsScreen() { ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle" } - separateBackground title="Model filters" > @@ -1414,6 +1419,7 @@ function ThreadSettingsModelsScreen() { ) : null} + ) : null} diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index c28f2e182a6f5..2abd5f6829c1f 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -5,6 +5,7 @@ import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "reac import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidSheetHeader } from "../../../components/ui/AndroidScreenHeader"; +import { SheetHeader } from "../../../components/ui/SheetHeader"; import { ScreenContent } from "../../../components/ui/ScreenContent"; import { NativeStackScreenOptions } from "../../../native/StackHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/ui/AppText"; @@ -82,11 +83,18 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { onBack={() => navigation.goBack()} hideBottomBorder /> - ) : null} + ) : ( + navigation.goBack()} + /> + )} - {content} + {content} - - - {content} - - - + navigation.goBack()} + /> + {content} ); } diff --git a/apps/mobile/src/features/threads/sidebar-native-header-items.ts b/apps/mobile/src/features/threads/sidebar-native-header-items.ts index c2f309a011edb..cfbaf755e89a4 100644 --- a/apps/mobile/src/features/threads/sidebar-native-header-items.ts +++ b/apps/mobile/src/features/threads/sidebar-native-header-items.ts @@ -31,30 +31,23 @@ function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHead ); } -export function createSidebarFilterHeaderItem(input: { - readonly filterIcon: string; - readonly filterMenu: HomeListFilterMenu; -}): NativeStackHeaderItemMenu { - return withNativeGlassHeaderItem({ - type: "menu", - axisBehavior: "horizontalOnly", - label: "", - accessibilityLabel: "Filter threads", - icon: sfSymbolIcon(input.filterIcon), - menu: { - title: input.filterMenu.title, - items: toNativeHeaderMenuItems(input.filterMenu.items), - }, - }); -} - export function createSidebarHeaderItems(input: { readonly filterIcon: string; readonly filterMenu: HomeListFilterMenu; readonly onOpenSettings: () => void; }): NativeStackHeaderItem[] { return [ - createSidebarFilterHeaderItem(input), + withNativeGlassHeaderItem({ + type: "menu", + axisBehavior: "horizontalOnly", + label: "", + accessibilityLabel: "Filter threads", + icon: sfSymbolIcon(input.filterIcon), + menu: { + title: input.filterMenu.title, + items: toNativeHeaderMenuItems(input.filterMenu.items), + }, + }), withNativeGlassHeaderItem({ type: "button", axisBehavior: "horizontalOnly", 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 b8c067ca826a2..93cddab7dc08b 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 @@ -2,6 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveThreadFeedLiveFollow, + resolveThreadFeedReflowAnchor, + resolveThreadFeedReflowOffset, + resolveThreadFeedReflowScroll, resolveThreadFeedSubmissionAnchor, resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, @@ -178,3 +181,115 @@ describe("resolveThreadFeedLiveFollow", () => { ).toBe(false); }); }); + +describe("width reflow anchors", () => { + const layout = { rowsTop: 50, headerInset: 100 }; + const feedState = ( + rows: ReadonlyArray<{ readonly id: string; readonly size: number }>, + scroll: number, + isWithinMaintainScrollAtEndThreshold = false, + ) => { + const positions = rows.map((_, index) => + rows.slice(0, index).reduce((top, row) => top + row.size, 0), + ); + return { + data: rows, + scroll, + isWithinMaintainScrollAtEndThreshold, + indexByKey: (key: string) => { + const index = rows.findIndex((row) => row.id === key); + return index < 0 ? undefined : index; + }, + positionAtIndex: (index: number) => positions[index], + sizes: new Map(rows.map((row) => [row.id, row.size])), + }; + }; + const wide = [ + { id: "question", size: 400 }, + { id: "reply", size: 2_000 }, + { id: "follow-up", size: 300 }, + ]; + // The line under the header sits 1,200px, 60%, into the reply. + const readingReply = feedState(wide, 1_550); + + it("keeps the end for a feed that is following it", () => { + expect(resolveThreadFeedReflowAnchor(feedState(wide, 1_550, true), layout, true)).toBe("end"); + }); + + it("keeps the reader's row after a status-bar scroll left follow on far from the end", () => { + expect(resolveThreadFeedReflowAnchor(readingReply, layout, true)).toEqual({ + key: "reply", + offsetWithinRow: 1_200, + size: 2_000, + }); + }); + + it.each([ + { change: "narrows", replySize: 4_000 }, + { change: "widens", replySize: 1_000 }, + ])("keeps the reader 60% into a reply when the feed $change", ({ replySize }) => { + const anchor = resolveThreadFeedReflowAnchor(readingReply, layout, false); + if (anchor === null || anchor === "end") throw new Error("expected a row anchor"); + const reflowed = feedState( + [ + { id: "question", size: 400 * (replySize / 2_000) }, + { id: "reply", size: replySize }, + { id: "follow-up", size: 300 }, + ], + readingReply.scroll, + ); + expect(resolveThreadFeedReflowScroll(anchor, reflowed, layout)).toEqual({ + index: 1, + viewOffset: layout.headerInset - 0.6 * replySize, + }); + }); + + it.each([ + { change: "narrows", replySize: 4_000 }, + { change: "widens", replySize: 1_000 }, + ])( + "holds the reader 60% into a reply on each re-measure as the feed $change", + ({ replySize }) => { + const anchor = resolveThreadFeedReflowAnchor(readingReply, layout, false); + if (anchor === null || anchor === "end") throw new Error("expected a row anchor"); + const rows = [ + { id: "question", size: 400 * (replySize / 2_000) }, + { id: "reply", size: replySize }, + { id: "follow-up", size: 300 }, + ]; + const offset = resolveThreadFeedReflowOffset( + anchor, + feedState(rows, readingReply.scroll), + layout, + ); + if (offset === undefined) throw new Error("expected an offset"); + expect(resolveThreadFeedReflowAnchor(feedState(rows, offset), layout, false)).toEqual({ + key: "reply", + offsetWithinRow: 0.6 * replySize, + size: replySize, + }); + }, + ); + + it("leaves a reader at the top of the thread where they are", () => { + expect(resolveThreadFeedReflowAnchor(feedState(wide, -layout.headerInset), layout, false)).toBe( + null, + ); + }); + + it("does not scroll when the row did not change size", () => { + const anchor = resolveThreadFeedReflowAnchor(readingReply, layout, false); + if (anchor === null || anchor === "end") throw new Error("expected a row anchor"); + expect(resolveThreadFeedReflowScroll(anchor, readingReply, layout)).toBeNull(); + }); + + it("does not restore a row that left the feed", () => { + expect( + resolveThreadFeedReflowScroll( + { key: "reply", offsetWithinRow: 1_200, size: 2_000 }, + feedState([{ id: "question", size: 400 }], 0), + layout, + ), + ).toBeNull(); + }); +}); 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 431dda7550d14..d0cfe299999b2 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -1,3 +1,5 @@ +import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; + export type ThreadFeedLiveFollowEvent = | { readonly type: "reset" } | { readonly type: "user-scroll-begin" } @@ -88,3 +90,89 @@ export function resolveThreadFeedLiveFollow( return current; } } + +/** The LegendList state a width reflow reads before rows re-measure and after they settle. */ +export interface ThreadFeedReflowListState { + readonly data: ReadonlyArray<{ readonly id: string }>; + readonly scroll: number; + readonly isWithinMaintainScrollAtEndThreshold: boolean; + readonly indexByKey: (key: string) => number | undefined; + readonly positionAtIndex: (index: number) => number | undefined; + readonly sizes: ReadonlyMap; +} + +export interface ThreadFeedReflowLayout { + /** List padding and header; row positions start below them. */ + readonly rowsTop: number; + /** The top of the viewport covered by the navigation header. */ + readonly headerInset: number; +} + +export interface ThreadFeedReflowRowAnchor { + readonly key: string; + readonly offsetWithinRow: number; + readonly size: number; +} + +/** A feed that follows keeps the end through a reflow; a reader keeps the row they are in. */ +export type ThreadFeedReflowAnchor = "end" | ThreadFeedReflowRowAnchor; + +function readingLine(state: ThreadFeedReflowListState, layout: ThreadFeedReflowLayout) { + return state.scroll + layout.headerInset - layout.rowsTop; +} + +export function resolveThreadFeedReflowAnchor( + state: ThreadFeedReflowListState, + layout: ThreadFeedReflowLayout, + following: boolean, +): ThreadFeedReflowAnchor | null { + // Follow alone is not enough: a status-bar tap scrolls to the top without a drag, so it + // leaves follow on while the reader is far from the end. + if (following && state.isWithinMaintainScrollAtEndThreshold) return "end"; + const line = readingLine(state, layout); + // A reader at the top of the thread stays there without help. + if (line <= 0) return null; + const row = resolveWorkGroupScrollAnchor({ + data: state.data, + scroll: line, + positionAtIndex: state.positionAtIndex, + }); + const size = row ? state.sizes.get(row.rowId) : undefined; + return row && size ? { key: row.rowId, offsetWithinRow: row.offsetWithinRow, size } : null; +} + +function resolveReflowAnchorPoint( + anchor: ThreadFeedReflowRowAnchor, + state: ThreadFeedReflowListState, +) { + const index = state.indexByKey(anchor.key); + const top = index === undefined ? undefined : state.positionAtIndex(index); + const size = state.sizes.get(anchor.key); + if (index === undefined || top === undefined || size === undefined) return null; + // Rewrapped text grows with its row, so the reader's line sits at the same share of the + // row rather than the same pixel offset into it. + return { index, top, offsetWithinRow: (anchor.offsetWithinRow * size) / anchor.size }; +} + +/** The scroll offset that puts the reader's line back under the header at the current sizes. */ +export function resolveThreadFeedReflowOffset( + anchor: ThreadFeedReflowRowAnchor, + state: ThreadFeedReflowListState, + layout: ThreadFeedReflowLayout, +): number | undefined { + const point = resolveReflowAnchorPoint(anchor, state); + return point + ? point.top + point.offsetWithinRow - layout.headerInset + layout.rowsTop + : undefined; +} + +export function resolveThreadFeedReflowScroll( + anchor: ThreadFeedReflowRowAnchor, + state: ThreadFeedReflowListState, + layout: ThreadFeedReflowLayout, +): { readonly index: number; readonly viewOffset: number } | null { + const point = resolveReflowAnchorPoint(anchor, state); + if (!point) return null; + if (Math.abs(readingLine(state, layout) - (point.top + point.offsetWithinRow)) < 1) return null; + return { index: point.index, viewOffset: layout.headerInset - point.offsetWithinRow }; +} diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index b36218cd29aa8..fd2748574075e 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { View } from "react-native"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; +import { NativeInspectorContent } from "../../native/NativeInspectorContent"; export type ThreadInspectorMode = "route" | "git" | "files"; @@ -33,7 +34,9 @@ function InspectorContentPane(props: { ( - + + + )} > {props.children} 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 725e6746022cd..235b41f1beb49 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -331,10 +331,34 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props readonly showTrailingDivider?: boolean; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; + readonly fullSwipeWidth?: number; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; + readonly activationKey?: string; + readonly simultaneousSwipeGesture?: ComponentProps["simultaneousWith"]; }) { const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; + const { width: windowWidth } = useWindowDimensions(); + const dormant = useSwipeRowDormant(props.activationKey); + const theme = useUniwindTheme(); const sidebarPane = props.pane === "sidebar"; + const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, false); const isDraft = pendingTask.kind === "draft"; + const deleteLabel = isDraft ? "Discard" : "Delete"; + const handleDelete = useCallback( + () => onDeletePendingTask(pendingTask), + [onDeletePendingTask, pendingTask], + ); + const deleteAction = useMemo( + () => ({ + accessibilityLabel: `${deleteLabel} ${pendingTask.title}`, + destructive: true, + icon: "trash" as const, + label: deleteLabel, + onPress: handleDelete, + }), + [deleteLabel, handleDelete, pendingTask.title], + ); const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? ""; const branch = pendingTask.branch; @@ -451,45 +475,65 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props {props.showPendingDivider ? ( ) : null} - - onSelectPendingTask(pendingTask)} - style={ - sidebarPane - ? { - borderRadius: SIDEBAR_V2_ROW_RADIUS, - paddingHorizontal: 12, - paddingVertical: 10, - } - : undefined - } - > - {sidebarPane ? ( - rowContent - ) : ( - - {rowContent} - {props.showTrailingDivider !== false ? ( - - ) : null} - - )} - - + {() => ( + + onSelectPendingTask(pendingTask)} + style={ + sidebarPane + ? { + borderRadius: SIDEBAR_V2_ROW_RADIUS, + paddingHorizontal: 12, + paddingVertical: 10, + } + : undefined + } + > + {sidebarPane ? ( + rowContent + ) : ( + + {rowContent} + {props.showTrailingDivider !== false ? ( + + ) : null} + + )} + + + )} + ); }); @@ -1377,7 +1421,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSwipeableWillOpen={props.onSwipeableWillOpen} primaryAction={primaryAction} secondaryAction={secondaryAction} - resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}`} + resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}:${thread.pinnedAt}`} simultaneousWith={props.simultaneousSwipeGesture} threadTitle={thread.title} > diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts index 14751cedd4025..60e37f9475d12 100644 --- a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts @@ -7,6 +7,28 @@ import { } from "./threadQueueControlPresentation"; describe("threadQueueControlPresentation", () => { + it.each([ + { busy: false, isEditing: false, expected: true }, + { busy: true, isEditing: false, expected: false }, + { busy: false, isEditing: true, expected: false }, + ])( + "gates sending a stopped thread's queued message with $busy busy and $isEditing editing", + ({ busy, isEditing, expected }) => { + const controls = resolveThreadQueueRowControls({ + busy, + isEditing, + canPromoteToSteer: false, + canSendNow: true, + canReorder: false, + index: 0, + queuedCount: 1, + text: "Queued message", + }); + + expect(controls.canSend).toBe(expected); + }, + ); + it("shows an assistant quote as its quoted text", () => { const href = formatAssistantCitationHref({ version: 1, @@ -22,6 +44,7 @@ describe("threadQueueControlPresentation", () => { const controls = resolveThreadQueueRowControls({ busy: false, canPromoteToSteer: true, + canSendNow: false, canReorder: true, index: 0, queuedCount: 1, @@ -37,6 +60,7 @@ describe("threadQueueControlPresentation", () => { const editing = resolveThreadQueueRowControls({ busy: false, canPromoteToSteer: true, + canSendNow: false, canReorder: true, index: 1, isEditing: true, @@ -46,7 +70,7 @@ describe("threadQueueControlPresentation", () => { expect(editing.isEditing).toBe(true); expect(editing.canEdit).toBe(false); - expect(editing.canSteer).toBe(false); + expect(editing.canSend).toBe(false); expect(editing.canMoveUp).toBe(true); expect(editing.canDismiss).toBe(true); }); diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts index 4128c874f9424..14fa9e028413f 100644 --- a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts +++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts @@ -8,7 +8,7 @@ export interface ThreadQueueRowControls { readonly canEdit: boolean; readonly canMoveDown: boolean; readonly canMoveUp: boolean; - readonly canSteer: boolean; + readonly canSend: boolean; readonly dismissAccessibilityLabel: string; readonly displayText: string; readonly isEditing: boolean; @@ -17,6 +17,7 @@ export interface ThreadQueueRowControls { export function resolveThreadQueueRowControls(input: { readonly busy: boolean; readonly canPromoteToSteer: boolean; + readonly canSendNow: boolean; readonly canReorder: boolean; readonly index: number; readonly isEditing?: boolean; @@ -33,9 +34,9 @@ export function resolveThreadQueueRowControls(input: { canEdit: mutationEnabled && !isEditing, canMoveDown: mutationEnabled && input.canReorder && input.index < input.queuedCount - 1, canMoveUp: mutationEnabled && input.canReorder && input.index > 0, - canSteer: mutationEnabled && input.canPromoteToSteer && !isEditing, + canSend: mutationEnabled && (input.canPromoteToSteer || input.canSendNow) && !isEditing, dismissAccessibilityLabel: REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL, - // Edit and Steer keep the original text so quoted citations stay linked. + // Editing and sending keep the original text so quoted citations stay linked. displayText: assistantCitationsToPlainText(input.text), isEditing, }; diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx index 366c2640b4c88..2153918ab0647 100644 --- a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx +++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx @@ -100,23 +100,6 @@ export function useThreadHeaderOptions(props: { [navigation], ); - const duoRightHeaderItems = useMemo( - () => [ - ...threadCenterHeaderItems, - { type: "spacing", spacing: 8 }, - withNativeGlassHeaderItem({ - type: "button" as const, - axisBehavior: "verticalPreferred", - identifier: "thread-right-new-task", - label: "New task", - accessibilityLabel: "New task", - icon: { name: "square.and.pencil", type: "sfSymbol" as const }, - onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), - }), - ], - [navigation, threadCenterHeaderItems], - ); - const options: AppNativeStackNavigationOptions = { headerShown: true, headerTitle: props.title, @@ -133,12 +116,9 @@ export function useThreadHeaderOptions(props: { : canGoBack ? undefined : () => compactHomeHeaderItems, + // The workspace navigator adds compose to the Duo detail column's bar. unstable_headerRightItems: () => - usesDuoHeader - ? duoRightHeaderItems - : layout.usesSplitView - ? threadCenterHeaderItems - : compactRightHeaderItems, + usesDuoHeader || layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, unstable_headerToolbarItems: () => [], unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, contentStyle: undefined, @@ -154,7 +134,6 @@ export function useThreadHeaderOptions(props: { splitLeftHeaderItems, threadCenterHeaderItems, compactRightHeaderItems, - duoRightHeaderItems, usesDuoHeader, environmentId, threadId, diff --git a/apps/mobile/src/features/threads/worktree-setup-card.tsx b/apps/mobile/src/features/threads/worktree-setup-card.tsx index 45a5bb9404d74..7495399edd2c4 100644 --- a/apps/mobile/src/features/threads/worktree-setup-card.tsx +++ b/apps/mobile/src/features/threads/worktree-setup-card.tsx @@ -84,7 +84,7 @@ export function WorktreeSetupCard(props: WorktreeSetupCardProps) { accessibilityRole="button" accessibilityLabel={ backgroundSetup - ? `${scriptName} is still running. Show setup progress.` + ? `Background setup is still running: ${scriptName}. Show setup progress.` : "Worktree setup details" } accessibilityState={{ expanded: detailsOpen }} @@ -116,7 +116,7 @@ export function WorktreeSetupCard(props: WorktreeSetupCardProps) { /> ) : null} - {backgroundSetup ? scriptName : "Details"} + {backgroundSetup ? "Background setup" : "Details"} {!backgroundSetup ? ( diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx index 98040a69c0f43..97a11068057c0 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx @@ -5,11 +5,13 @@ import { useKeyboardState } from "react-native-keyboard-controller"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { CompactBrandTitle } from "../../components/CompactBrandTitle"; +import { isDuoDevice } from "../../lib/device-type"; import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { useDeviceType, + useDuoBottomBarInset, useNativeColumnLayoutMetrics, useNativeLayoutMetrics, } from "../../native/native-layout-metrics"; @@ -24,7 +26,7 @@ import { useWorkspaceTabs, } from "./WorkspaceTabsContext"; import { WorkspaceToolbar } from "./WorkspaceToolbar"; -import { WORKSPACE_DOCK, workspaceToolbarHeight } from "./workspace-tabs-state"; +import { WORKSPACE_DOCK, WORKSPACE_TOOLBAR, workspaceToolbarHeight } from "./workspace-tabs-state"; import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs"; import { useSidebarWorkspaceTabs, useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar"; @@ -69,6 +71,7 @@ function WorkspaceRootContent() { (nativeMetrics !== null && nativeMetrics.verticalBarEdge !== "none")); const width = columnMetrics?.width ?? windowWidth; const insets = useSafeAreaInsets(); + const bottomBarInset = useDuoBottomBarInset(); const keyboardHeight = useKeyboardState((keyboard) => keyboard.height); const workspace = useWorkspaceTabs()!; const { state } = workspace; @@ -88,14 +91,18 @@ function WorkspaceRootContent() { ); const tab = state.tabs[state.selectedTab]; const bottomClearance = - Platform.OS === "ios" - ? Math.max(insets.bottom, keyboardHeight) + - (systemTabBar || sidebarTabs || tab.searchMode !== "resting" - ? 16 - : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16) - : keyboardHeight > 0 && tab.searchMode !== "resting" - ? keyboardHeight + 64 - : Math.max(insets.bottom, 12) + workspaceToolbarHeight(tab.searchMode) + 16; + Platform.OS === "ios" && isDuoDevice(deviceType) + ? Math.max(bottomBarInset ?? Math.max(insets.bottom, 12), keyboardHeight + 8) + + WORKSPACE_TOOLBAR.height + + 16 + : Platform.OS === "ios" + ? Math.max(insets.bottom, keyboardHeight) + + (systemTabBar || sidebarTabs || tab.searchMode !== "resting" + ? 16 + : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16) + : keyboardHeight > 0 && tab.searchMode !== "resting" + ? keyboardHeight + 64 + : Math.max(insets.bottom, 12) + workspaceToolbarHeight(tab.searchMode) + 16; const context = useMemo(() => ({ ...workspace, bottomClearance }), [workspace, bottomClearance]); return ( @@ -112,7 +119,6 @@ function WorkspaceRootContent() { headerShown: true, unstable_headerLeftItems: () => [], headerTitle: renderHeaderTitle, - ...(state.selectedTab === "threads" ? {} : { nativeSidebarFilter: undefined }), }} /> ) : ( diff --git a/apps/mobile/src/features/workspace/WorkspaceSearchField.tsx b/apps/mobile/src/features/workspace/WorkspaceSearchField.tsx new file mode 100644 index 0000000000000..98fb746e71d19 --- /dev/null +++ b/apps/mobile/src/features/workspace/WorkspaceSearchField.tsx @@ -0,0 +1,60 @@ +import type { Ref } from "react"; +import { Keyboard, Pressable, TextInput, View, type TextInputInstance } from "react-native"; +import { useResolveClassNames } from "uniwind"; + +import { SymbolView } from "../../components/ui/AppSymbol"; +import { GlassSurface } from "../../components/ui/GlassSurface"; +import { WORKSPACE_TOOLBAR, type WorkspaceTab } from "./workspace-tabs-state"; + +export function WorkspaceSearchField({ + ref, + ...props +}: { + readonly ref?: Ref; + readonly tab: WorkspaceTab; + readonly query: string; + readonly onChangeQuery: (query: string) => void; + readonly onFocus: () => void; + readonly onBlur?: () => void; + readonly onClear: () => void; +}) { + const inputStyle = useResolveClassNames("text-foreground font-sans text-base"); + const placeholderStyle = useResolveClassNames("text-foreground-muted"); + const label = props.tab === "pullRequests" ? "Search pull requests" : "Search threads"; + return ( + + + + Keyboard.dismiss()} + autoCapitalize="none" + autoCorrect={false} + returnKeyType="search" + maxLength={props.tab === "pullRequests" ? 200 : undefined} + style={[inputStyle, { flex: 1, minWidth: 0, paddingHorizontal: 8, paddingVertical: 0 }]} + /> + {props.query ? ( + + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx index af815639fabc1..00febb5781f96 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx @@ -1,16 +1,24 @@ import { Toolbar } from "../../components/ui/Toolbar"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; -import { use, useCallback, useEffect, useRef } from "react"; -import { Platform } from "react-native"; -import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; +import { use, useCallback, useEffect, useRef, type ReactNode } from "react"; +import { Platform, Pressable, type TextInputInstance } from "react-native"; +import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { isDuoDevice } from "../../lib/device-type"; +import { useDeviceType, useDuoBottomBarInset } from "../../native/native-layout-metrics"; import type { SearchBarCommands } from "react-native-screens"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { GlassSurface } from "../../components/ui/GlassSurface"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { NativeWorkspaceTabActivityContext } from "../../native/v5-workspace-context"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; +import { WorkspaceSearchField } from "./WorkspaceSearchField"; +import { WORKSPACE_TOOLBAR } from "./workspace-tabs-state"; import { useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar"; import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; @@ -18,26 +26,61 @@ import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; // Tabler's git-pull-request geometry, shared with the UIKit tab bar. const PULL_REQUEST_ICON = require("../../../assets/icons/pull-request.png"); +/** + * Rises once the keyboard reaches its content. UIKit also reports keyboards without height, with a + * hardware keyboard and around a fold, and those must not move it. + */ +function AboveKeyboard(props: { readonly bottom: number; readonly children: ReactNode }) { + const keyboard = useReanimatedKeyboardAnimation(); + const { bottom } = props; + const lift = useAnimatedStyle(() => ({ + transform: [{ translateY: Math.min(0, keyboard.height.value + bottom - 8) }], + })); + return ( + + {props.children} + + ); +} + export function WorkspaceToolbar(props: { readonly onNewTask: () => void; readonly sidebar?: boolean; }) { const workspace = useWorkspaceTabs()!; const navigation = useNavigation(); - const metrics = useNativeLayoutMetrics(); const deviceType = useDeviceType(); const sidebarTabs = useSidebarWorkspaceTabs(); const selectTab = useSelectWorkspaceTab(); const selectedTint = useMobileNavigationTheme().colors.primary; + // On the Duo, compose belongs to the threads tab, in the detail column's bar when unfolded. const nativeToolbarCompose = Platform.OS === "ios" && - (deviceType === "pad" || - sidebarTabs !== null || - (metrics?.verticalBarEdge ?? "none") !== "none"); + (isDuoDevice(deviceType) + ? deviceType === "duo-folded" && workspace.state.selectedTab === "threads" + : deviceType === "pad"); + // The Duo searches from a field at the bottom of the list in every pose. UIKit has no such + // placement once the vertical bar holds the toolbar, and re-placing its search controller on + // each fold replaces the search field. + const listSearch = isDuoDevice(deviceType); + const insets = useSafeAreaInsets(); + const bottomBarInset = useDuoBottomBarInset(); const { isTabActive } = workspace; const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true; const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const searchRef = useRef(null); + const fieldRef = useRef(null); const tab = workspace.state.tabs[workspace.state.selectedTab]; const selectedTab = workspace.state.selectedTab; const enterSearch = useCallback(() => { @@ -51,10 +94,25 @@ export function WorkspaceToolbar(props: { }, [workspace, selectedTab, props.sidebar, panes.primarySidebarVisible, togglePrimarySidebar]); const focusSearch = useCallback(() => { if (!workspace.isTabActive(selectedTab)) return false; + if (listSearch) { + fieldRef.current?.focus(); + return true; + } if (!NATIVE_LIQUID_GLASS_SUPPORTED) enterSearch(); searchRef.current?.focus(); return true; - }, [workspace, selectedTab, enterSearch]); + }, [workspace, selectedTab, listSearch, enterSearch]); + const cancelSearch = useCallback(() => { + workspace.recordScrollOffset(selectedTab, tab.searchEntryOffset); + workspace.dispatch({ type: "cancel", tab: selectedTab }); + }, [workspace, selectedTab, tab.searchEntryOffset]); + const searchFocused = useCallback( + () => + tab.searchMode === "resting" + ? enterSearch() + : workspace.dispatch({ type: "search", tab: selectedTab }), + [tab.searchMode, enterSearch, workspace, selectedTab], + ); useHardwareKeyboardCommand("focusSearch", focusSearch); const restoreSearch = useCallback(() => { if (isTabActive(selectedTab) && tab.searchMode !== "resting") @@ -79,7 +137,7 @@ export function WorkspaceToolbar(props: { // Liquid Glass keeps search mounted as UIKit's own header button, which // morphs into the field; swapping a stacked bar in and out cannot animate. headerSearchBarOptions: - NATIVE_LIQUID_GLASS_SUPPORTED || tab.searchMode !== "resting" + !listSearch && (NATIVE_LIQUID_GLASS_SUPPORTED || tab.searchMode !== "resting") ? { ref: searchRef, autoFocus: tab.searchMode === "active", @@ -92,10 +150,7 @@ export function WorkspaceToolbar(props: { placeholder: selectedTab === "pullRequests" ? "Search pull requests" : "Search threads", cancelButtonText: "✕", - onFocus: () => - tab.searchMode === "resting" - ? enterSearch() - : workspace.dispatch({ type: "search", tab: selectedTab }), + onFocus: searchFocused, onBlur: () => workspace.dispatch({ type: "expand", tab: selectedTab }), onSearchButtonPress: () => searchRef.current?.blur(), onChangeText: ({ nativeEvent }) => @@ -104,10 +159,7 @@ export function WorkspaceToolbar(props: { query: nativeEvent.text, tab: selectedTab, }), - onCancelButtonPress: () => { - workspace.recordScrollOffset(selectedTab, tab.searchEntryOffset); - workspace.dispatch({ type: "cancel", tab: selectedTab }); - }, + onCancelButtonPress: cancelSearch, } : undefined, }} @@ -164,6 +216,45 @@ export function WorkspaceToolbar(props: { /> ) : null} + {listSearch ? ( + + + workspace.dispatch({ type: "query", query, tab: selectedTab }) + } + onFocus={searchFocused} + onBlur={() => workspace.dispatch({ type: "expand", tab: selectedTab })} + onClear={() => { + workspace.dispatch({ type: "query", query: "", tab: selectedTab }); + fieldRef.current?.focus(); + }} + /> + {tab.searchMode === "resting" ? null : ( + + { + cancelSearch(); + fieldRef.current?.blur(); + }} + className="flex-1 items-center justify-center" + > + + + + )} + + ) : null} ); } diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.tsx b/apps/mobile/src/features/workspace/WorkspaceToolbar.tsx index a659bdcd33d08..73c8a9e8733df 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.tsx @@ -5,19 +5,18 @@ import { Keyboard, Platform, Pressable, - TextInput, View, useWindowDimensions, type TextInputInstance, } from "react-native"; import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useResolveClassNames } from "uniwind"; import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { GlassSurface } from "../../components/ui/GlassSurface"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { WorkspaceDock } from "./WorkspaceDock"; +import { WorkspaceSearchField } from "./WorkspaceSearchField"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { WORKSPACE_TOOLBAR, type WorkspaceTab } from "./workspace-tabs-state"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; @@ -60,8 +59,6 @@ export function WorkspaceToolbar(props: { const edge = width < 360 ? 12 : WORKSPACE_TOOLBAR.edge; const gap = width < 360 ? 6 : WORKSPACE_TOOLBAR.gap; const keyboardVisible = useKeyboardState((state) => state.isVisible); - const inputStyle = useResolveClassNames("text-foreground font-sans text-base"); - const placeholderStyle = useResolveClassNames("text-foreground-muted"); const focusSearch = useCallback(() => { if (!workspace) return false; const tab = workspace.state.selectedTab; @@ -126,50 +123,17 @@ export function WorkspaceToolbar(props: { onPress={() => dispatch({ type: "expand" })} /> ) : null} - - - - dispatch({ type: "query", query })} - onFocus={() => dispatch({ type: "search" })} - onSubmitEditing={() => Keyboard.dismiss()} - autoCapitalize="none" - autoCorrect={false} - returnKeyType="search" - maxLength={state.selectedTab === "pullRequests" ? 200 : undefined} - style={[ - inputStyle, - { flex: 1, minWidth: 0, paddingHorizontal: 8, paddingVertical: 0 }, - ]} - /> - {tab.query ? ( - { - dispatch({ type: "query", query: "" }); - focusSearch(); - }} - className="h-11 w-11 items-center justify-center" - > - - - ) : null} - - + dispatch({ type: "query", query })} + onFocus={() => dispatch({ type: "search" })} + onClear={() => { + dispatch({ type: "query", query: "" }); + focusSearch(); + }} + /> ) : null} diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 8b1e344826e60..111bb2b40fce6 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -58,6 +58,8 @@ export interface Layout { export interface WorkspacePaneLayout { readonly primarySidebarVisible: boolean; + /** Whether the sidebar shows once a collapsed split expands; independent of the current width. */ + readonly primarySidebarWhenExpanded: boolean; readonly primarySidebarSuppressedByAuxiliary: boolean; readonly contentPaneWidth: number; readonly supportsAuxiliaryPane: boolean; @@ -215,6 +217,7 @@ export function deriveWorkspacePaneLayout(input: { return { primarySidebarVisible, + primarySidebarWhenExpanded: input.primarySidebarPreferredVisible, primarySidebarSuppressedByAuxiliary, contentPaneWidth: Math.max(0, viewportWidth - primarySidebarWidth), supportsAuxiliaryPane: fileInspector.supported, @@ -235,6 +238,7 @@ export function deriveWorkspacePaneLayout(input: { return { primarySidebarVisible: preferredPrimarySidebarVisible, + primarySidebarWhenExpanded: input.primarySidebarPreferredVisible, primarySidebarSuppressedByAuxiliary: false, contentPaneWidth, supportsAuxiliaryPane, diff --git a/apps/mobile/src/lib/markdownMedia.test.ts b/apps/mobile/src/lib/markdownMedia.test.ts index 33fec670e41ea..ef7cca311146f 100644 --- a/apps/mobile/src/lib/markdownMedia.test.ts +++ b/apps/mobile/src/lib/markdownMedia.test.ts @@ -38,10 +38,10 @@ describe("resolveMarkdownMediaPreview", () => { ["/tmp/frame%23one.png:12", "/tmp/frame#one.png"], ["/tmp/frame%3Fone.png:12:3", "/tmp/frame?one.png"], ["/tmp/frame%2523one.png:12", "/tmp/frame%23one.png"], - ["file://server/share/frame.png", "\\\\server\\share\\frame.png"], - ["\\\\server\\share\\frame.png", "\\\\server\\share\\frame.png"], - ])("keeps encoded filename and UNC semantics for %s", (href, path) => { - expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ + ["file://server/share/frame.png", "\\\\server\\share\\frame.png", "\\\\server\\share"], + ["\\\\server\\share\\frame.png", "\\\\server\\share\\frame.png", "\\\\server\\share"], + ])("keeps encoded filename and UNC semantics for %s", (href, path, workspaceRoot = "/repo") => { + expect(resolveMarkdownMediaPreview(href, { ...input, workspaceRoot })).toMatchObject({ kind: "image", source: { resource: { path }, diff --git a/apps/mobile/src/lib/reserved-regions.ts b/apps/mobile/src/lib/reserved-regions.ts index c212dffd311f4..f88af9239c367 100644 --- a/apps/mobile/src/lib/reserved-regions.ts +++ b/apps/mobile/src/lib/reserved-regions.ts @@ -19,6 +19,8 @@ export interface NativeLayoutMetrics { readonly right: number; }; readonly reservedRegions: ReadonlyArray; + /** How far above the bottom edge UIKit floats a 48pt bottom bar, level with its tab bar. iOS 27.1+. */ + readonly bottomBarInset?: number; } export function deriveBottomControlInsets(metrics: NativeLayoutMetrics | null) { diff --git a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx index 3c522bf7dc6bf..60fe05661f4cb 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx @@ -3,22 +3,34 @@ import { act, createContext, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, expect, it, vi } from "vite-plus/test"; +type HeaderOptions = { + unstable_headerLeftItems?: () => NativeStackHeaderItem[]; + unstable_headerRightItems?: () => NativeStackHeaderItem[]; + unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; +}; + const harness = vi.hoisted(() => ({ params: { path: ["first", "guide.pdf"] }, + appliedOptions: {} as HeaderOptions, renderedItems: [] as NativeStackHeaderItem[], setOptionsCalls: 0, openedDocuments: [] as string[], })); -// Native headers retain the actions materialized when navigation applies options. +// Native headers retain the actions materialized when navigation applies options. Like React +// Navigation, the header renders again only when the options are no longer shallowly equal. vi.mock("@react-navigation/native", () => { const navigation = { - setOptions(options: { - unstable_headerLeftItems?: () => NativeStackHeaderItem[]; - unstable_headerRightItems?: () => NativeStackHeaderItem[]; - unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; - }) { + setOptions(options: HeaderOptions) { harness.setOptionsCalls += 1; + const previous: Record = harness.appliedOptions; + const next: Record = { ...previous, ...options }; + harness.appliedOptions = next; + if ( + Object.keys(next).length === Object.keys(previous).length && + Object.keys(next).every((key) => next[key] === previous[key]) + ) + return; harness.renderedItems = options.unstable_headerLeftItems?.() ?? options.unstable_headerRightItems?.() ?? @@ -104,6 +116,7 @@ function openRenderedDocument() { afterEach(() => { act(() => root?.unmount()); root = null; + harness.appliedOptions = {}; harness.renderedItems = []; harness.setOptionsCalls = 0; harness.openedDocuments = []; diff --git a/apps/mobile/src/native/NativeHeaderToolbar.tsx b/apps/mobile/src/native/NativeHeaderToolbar.tsx index 2e6e7059880b9..3f7f98a4b2324 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.tsx @@ -42,6 +42,10 @@ type NativeStackHeaderAxisBehavior = Extract< NativeStackHeaderItem, { type: "button" } >["axisBehavior"]; +type NativeStackHeaderVisibilityPriority = Extract< + NativeStackHeaderItem, + { type: "button" } +>["visibilityPriority"]; type NativeToolbarItem = NativeStackHeaderItem | HeaderBarButtonSearchBarPlacementItem; type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { unstable_headerToolbarItems?: () => NativeToolbarItem[]; @@ -166,6 +170,7 @@ function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { ? child.props.variant : "plain", identifier: typeof child.props.identifier === "string" ? child.props.identifier : undefined, + visibilityPriority: child.props.visibilityPriority as NativeStackHeaderVisibilityPriority, }; } @@ -277,6 +282,7 @@ function NativeHeaderToolbarButton(_props: { readonly tintColor?: ColorValue; readonly variant?: "plain" | "done" | "prominent"; readonly identifier?: string; + readonly visibilityPriority?: NativeStackHeaderVisibilityPriority; }) { return null; } diff --git a/apps/mobile/src/native/NativeInspectorContent.tsx b/apps/mobile/src/native/NativeInspectorContent.tsx new file mode 100644 index 0000000000000..4907618f2d4b6 --- /dev/null +++ b/apps/mobile/src/native/NativeInspectorContent.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { useNativeWorkspaceColumnsSupported } from "./NativeWorkspaceColumns"; + +// Pads with the inspector column's insets, which the column leaves to its panes so their +// native headers lay out against the vertical bar themselves. +export function NativeInspectorContent(props: { readonly children: ReactNode }) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); + if (!usesNativeWorkspaceColumns) return <>{props.children}; + + return ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/native/NativeSheetContent.tsx b/apps/mobile/src/native/NativeSheetContent.tsx deleted file mode 100644 index ec44dc0dde2b6..0000000000000 --- a/apps/mobile/src/native/NativeSheetContent.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { ReactNode } from "react"; -import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; - -import { useNativeWorkspaceColumnsSupported } from "./NativeWorkspaceColumns"; - -export function NativeSheetContent(props: { readonly children: ReactNode }) { - const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); - if (!usesNativeWorkspaceColumns) return <>{props.children}; - - return ( - - - {props.children} - - - ); -} diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 698a345035cca..ae9783f39dd6c 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -1,6 +1,5 @@ import { useNavigation, type ParamListBase } from "@react-navigation/native"; import type { - NativeStackHeaderItemMenu, NativeStackNavigationOptions, NativeStackNavigationProp, } from "@react-navigation/native-stack"; @@ -20,7 +19,6 @@ export type AppNativeStackNavigationOptions = Omit< "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems" > & { readonly nativeContentInsetHorizontally?: boolean; - readonly nativeSidebarFilter?: NativeStackHeaderItemMenu; readonly headerTintColor?: string | ColorValue; readonly unstable_headerCenterItems?: unknown; readonly unstable_headerLeftItems?: unknown; @@ -201,8 +199,19 @@ export function NativeStackScreenOptions(props: { return; } lastAppliedOptionsSignatureRef.current = signature; - navigation.setOptions(stableOptions); - }, [navigation, props.optionsVersion, stableOptions, tabActive]); + // React Navigation keeps a screen's descriptor while its options are shallowly equal. The + // stabilized factories keep their identity, so one whose output changed needs a new wrapper + // to reach the header. + optionFunctionWrappersRef.current = new Map(); + navigation.setOptions( + stabilizeOptionFunctions( + normalizedOptions, + "options", + latestOptionFunctionsRef.current, + optionFunctionWrappersRef.current, + ) as NativeStackNavigationOptions, + ); + }, [navigation, normalizedOptions, props.optionsVersion, stableOptions, tabActive]); useEffect(() => { if (!navigation || !props.listeners) { diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx index 85419d80353e5..13720678c3f37 100644 --- a/apps/mobile/src/native/V5StackHeader.ios.tsx +++ b/apps/mobile/src/native/V5StackHeader.ios.tsx @@ -128,6 +128,8 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte axisBehavior: item.axisBehavior, icon: convertIcon(item.icon), onPress: item.disabled ? undefined : item.onPress, + visibilityPriority: + item.visibilityPriority === "default" ? "standard" : item.visibilityPriority, variant: item.variant, tintColor: item.tintColor, hidesSharedBackground: item.hidesSharedBackground, @@ -139,36 +141,32 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte export function V5StackHeader(props: { readonly options: Options; readonly canGoBack: boolean; - readonly primary?: boolean; - readonly sidebarFilter?: NativeStackHeaderItemMenu; + /** Workspace split columns share the vertical bar with the workspace tab bar. */ + readonly column?: "primary" | "detail"; + /** The workspace's compose action, shown as its own group in this column's bar. */ + readonly onCompose?: () => void; }) { const { options } = props; const theme = useMobileNavigationTheme(); const nativeMetrics = useNativeLayoutMetrics(); const deviceType = useDeviceType(); const searchRef = useRef(null); - const usesDuoToolbarRail = - deviceType === "duo-unfolded" && (nativeMetrics?.verticalBarEdge ?? "none") !== "none"; const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); - const trailingItems = [ - ...(options.unstable_headerCenterItems?.() ?? []), - ...(options.unstable_headerRightItems?.(itemProps) ?? []), - ]; - // Keep the sidebar's filter actions when UIKit moves the menu to the detail rail. - if (usesDuoToolbarRail && !props.primary && props.sidebarFilter) { - trailingItems.push({ - ...props.sidebarFilter, - identifier: "home-filter", - axisBehavior: "verticalPreferred", - }); - } - const trailing = convertItems(trailingItems, "trailing"); + const trailing = convertItems( + [ + ...(options.unstable_headerCenterItems?.() ?? []), + ...(options.unstable_headerRightItems?.(itemProps) ?? []), + ], + "trailing", + ); const toolbar = options.unstable_headerToolbarItems?.() ?? []; const mailSearch = toolbar.find( (item): item is HeaderBarButtonMailSearchToolbarItem => item.type === "mailSearchToolbar", ); const bottomSearch = Boolean(mailSearch) && deviceType !== "pad"; + // A vertical bar keeps fixed spaces at full length and separates items itself. + const spacesSearch = bottomSearch && (nativeMetrics?.verticalBarEdge ?? "none") === "none"; const bottom = toolbar.flatMap((item, index) => { if (item.type === "mailSearchToolbar") return []; if (item.type === "searchBarPlacement") @@ -180,7 +178,6 @@ export function V5StackHeader(props: { bottom.push({ type: "item", id: mailSearch.filterButtonId ?? "filter", - axisBehavior: bottomSearch ? "horizontalOnly" : undefined, icon: { type: "sfSymbol", name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease", @@ -188,37 +185,46 @@ export function V5StackHeader(props: { menu: convertMenu(mailSearch.filterMenu, "filter-menu"), }); if (bottomSearch) { - if (bottom.length) + if (spacesSearch && bottom.length) bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 }); - bottom.push({ - type: "item", - id: "home-search", - axisBehavior: "horizontalOnly", - searchBarPlacement: true, - }); - if (mailSearch.onComposePress) + bottom.push({ type: "item", id: "home-search", searchBarPlacement: true }); + if (spacesSearch && mailSearch.onComposePress) bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 }); } if (mailSearch.onComposePress) bottom.push({ type: "item", id: mailSearch.composeButtonId ?? "compose", - axisBehavior: bottomSearch ? "horizontalOnly" : undefined, identifier: "workspace-trailing-action", icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" }, onPress: mailSearch.onComposePress, }); } - // Duo keeps compose at the bottom of a visible sidebar. On a single panel, - // the primary column owns the rail too; its separate UIToolbar is inaccessible. + // UIKit doesn't stack a column's toolbar against the workspace tab bar, which owns the + // bottom of the vertical bar and draws over it. Those columns keep the group in their + // navigation items. An expanded sidebar's toolbar stays horizontal, outside the bar. if ( + props.column && nativeMetrics && nativeMetrics.verticalBarEdge !== "none" && - (!props.primary || nativeMetrics.horizontalSizeClass === "compact") + (props.column === "detail" || nativeMetrics.horizontalSizeClass === "compact") ) { + if (trailing.length && bottom.length) + trailing.push({ type: "spacer", id: "toolbar-group", sizing: "fixed", width: 8 }); trailing.push(...bottom); bottom.length = 0; } + if (props.onCompose) { + if (trailing.length) + trailing.push({ type: "spacer", id: "compose-group", sizing: "fixed", width: 8 }); + trailing.push({ + type: "item", + id: "workspace-compose", + title: "New task", + icon: { type: "sfSymbol", name: "square.and.pencil" }, + onPress: props.onCompose, + }); + } const left = options.headerLeft?.(itemProps); const right = options.headerRight?.(itemProps); if (!options.unstable_headerLeftItems && isValidElement(left)) diff --git a/apps/mobile/src/native/createV5StackNavigator.ios.tsx b/apps/mobile/src/native/createV5StackNavigator.ios.tsx index 6433ab5002019..1c8cea900618f 100644 --- a/apps/mobile/src/native/createV5StackNavigator.ios.tsx +++ b/apps/mobile/src/native/createV5StackNavigator.ios.tsx @@ -369,6 +369,7 @@ export function V5SheetStackView(props: V5StackViewProps) { initialDetentIndex={options.sheetInitialDetentIndex} largestUndimmedDetentIndex={options.sheetLargestUndimmedDetentIndex} prefersGrabberVisible={options.sheetGrabberVisible} + prefersHorizontalBars={options.sheetPrefersHorizontalBars} preferredCornerRadius={options.sheetCornerRadius} prefersScrollingExpandsWhenScrolledToEdge={options.sheetExpandsWhenScrolledToEdge} nativeContainerStyle={{ diff --git a/apps/mobile/src/native/native-layout-metrics.tsx b/apps/mobile/src/native/native-layout-metrics.tsx index 44d12142d522e..195e7a2cec050 100644 --- a/apps/mobile/src/native/native-layout-metrics.tsx +++ b/apps/mobile/src/native/native-layout-metrics.tsx @@ -1,6 +1,6 @@ import { createContext, use, useState, type ReactNode } from "react"; import { Platform } from "react-native"; -import { deriveDeviceType, type DeviceType } from "../lib/device-type"; +import { deriveDeviceType, isDuoDevice, type DeviceType } from "../lib/device-type"; import type { NativeLayoutMetrics } from "../lib/reserved-regions"; import { NativeLayoutObserver } from "./NativeLayoutObserver"; @@ -38,3 +38,13 @@ export function useNativeColumnLayoutMetrics() { export function useDeviceType() { return use(DeviceTypeContext); } + +/** + * Where the Duo's floating bottom controls end in this column. Its tab bar floats below the safe + * area, and the controls beside it sit on the same line. + */ +export function useDuoBottomBarInset() { + const deviceType = useDeviceType(); + const inset = use(NativeColumnLayoutMetricsContext)?.bottomBarInset; + return isDuoDevice(deviceType) ? inset : undefined; +} diff --git a/apps/mobile/src/native/sheet-surface.ts b/apps/mobile/src/native/sheet-surface.ts index 7867b2b77e81f..9de5943a008e0 100644 --- a/apps/mobile/src/native/sheet-surface.ts +++ b/apps/mobile/src/native/sheet-surface.ts @@ -1,3 +1,11 @@ -export const FORM_SHEET_PRESENTATION_OPTIONS = { +import { Platform } from "react-native"; + +export const HORIZONTAL_FORM_SHEET_OPTIONS = { presentation: "formSheet" as const, + sheetPrefersHorizontalBars: true, }; + +export function formSheetDetents(initialDetent: number, androidExpandedDetent = 0.92) { + // Duo moves the status bar above the sheet only at the maximum native detent. + return [initialDetent, Platform.OS === "ios" ? 1 : androidExpandedDetent]; +} diff --git a/apps/mobile/src/native/useRestingTopOffset.test.tsx b/apps/mobile/src/native/useRestingTopOffset.test.tsx new file mode 100644 index 0000000000000..7be7e1d82c3b1 --- /dev/null +++ b/apps/mobile/src/native/useRestingTopOffset.test.tsx @@ -0,0 +1,73 @@ +import { act, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { useRestingTopOffset } from "./useRestingTopOffset"; + +let root: Root | null = null; +let latest: ReturnType | null = null; + +function Probe(props: { readonly topInset: number; readonly restoredOffset?: number }) { + const restingOffset = useRestingTopOffset( + props.topInset, + props.restoredOffset === undefined ? {} : { contentOffset: { x: 0, y: props.restoredOffset } }, + ); + useLayoutEffect(() => { + latest = restingOffset; + }); + return null; +} + +function render(topInset: number, restoredOffset?: number) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root ??= createRoot(document.createElement("div")); + act(() => root!.render()); +} + +function scrollTo(y: number) { + const event = { nativeEvent: { contentOffset: { x: 0, y } } } as Parameters< + ReturnType["onScroll"] + >[0]; + act(() => latest!.onScroll(event)); +} + +function offset() { + return latest!.contentOffset.y; +} + +afterEach(() => { + act(() => root?.unmount()); + root = null; + latest = null; +}); + +describe("useRestingTopOffset", () => { + it("keeps an untouched list below the header once the column's inset arrives", () => { + // The folded iPhone Duo's window has no top inset; the header's arrives after mount. + render(0); + render(54); + expect(offset()).toBe(-54); + }); + + it("does not move a list the reader scrolled when the inset changes", () => { + render(54); + scrollTo(300); + render(90); + expect(offset()).toBe(-54); + }); + + it("follows the inset again once the list is back at the top", () => { + render(54); + scrollTo(300); + render(90); + scrollTo(-90); + render(54); + expect(offset()).toBe(-54); + }); + + it("holds a restored position through an inset change", () => { + render(0, 1_200); + render(54); + expect(offset()).toBe(1_200); + }); +}); diff --git a/apps/mobile/src/native/useRestingTopOffset.ts b/apps/mobile/src/native/useRestingTopOffset.ts new file mode 100644 index 0000000000000..33fc6bd09b472 --- /dev/null +++ b/apps/mobile/src/native/useRestingTopOffset.ts @@ -0,0 +1,29 @@ +import { useState } from "react"; +import type { NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps } from "react-native"; + +/** + * A scroll view with a manual top inset rests at `-topInset`, just below the header. The inset + * can change after mount: a column's safe area starts as the window's until UIKit measures it, + * and rotation or folding changes it again. While the view rests below the header it follows the + * inset; once scrolled away it holds the last resting offset, because RN re-applies a changed + * `contentOffset` prop and would move the reader. + */ +export function holdRestingTopOffset(held: number | null, scrollY: number, topInset: number) { + return Math.abs(scrollY + topInset) <= 1 ? null : (held ?? -topInset); +} + +export function useRestingTopOffset( + topInset: number, + props: Pick, +) { + // A list restoring a saved position mounts away from its resting offset. + const [held, setHeld] = useState(props.contentOffset?.y ?? null); + return { + contentOffset: { x: 0, y: held ?? -topInset }, + onScroll: (event: NativeSyntheticEvent) => { + const scrollY = event.nativeEvent.contentOffset.y; + setHeld((current) => holdRestingTopOffset(current, scrollY, topInset)); + props.onScroll?.(event); + }, + }; +} diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index fc6c04aa5833b..f51c729f319e4 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -66,8 +66,7 @@ interface CompilerEvent { // babel.config.js is the file Metro loads; the logger rides on the preset's // own react-compiler option so the plugin runs with Metro's exact settings. -function compilerBailouts(file: string): string[] { - const events: CompilerEvent[] = []; +function compileLikeMetro(file: string, events: CompilerEvent[], output: Record) { const babelConfig = requireFromMobile("./babel.config.js")({ cache: () => {} }); const presets = babelConfig.presets.map(([name, options]: [string, object]) => name === "babel-preset-expo" @@ -82,14 +81,20 @@ function compilerBailouts(file: string): string[] { ] : [name, options], ); - babel.transformFileSync(NodePath.join(mobileRoot, file), { + return babel.transformFileSync(NodePath.join(mobileRoot, file), { babelrc: false, configFile: false, cwd: mobileRoot, presets, caller: metroCaller, code: false, + ...output, }); +} + +function compilerBailouts(file: string): string[] { + const events: CompilerEvent[] = []; + compileLikeMetro(file, events, {}); return events .filter((event) => event.kind === "CompileError" || event.kind === "CompileSkip") .map( @@ -98,6 +103,47 @@ function compilerBailouts(file: string): string[] { ); } +interface AstNode { + readonly type: string; + readonly [key: string]: unknown; +} + +const isAstNode = (value: unknown): value is AstNode => + typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"; + +function walkAst( + value: unknown, + visit: (node: AstNode, ancestors: readonly AstNode[]) => void, + ancestors: readonly AstNode[] = [], +) { + if (typeof value !== "object" || value === null) return; + const node = isAstNode(value) ? value : null; + if (node) visit(node, ancestors); + const childAncestors = node ? [...ancestors, node] : ancestors; + for (const child of Object.values(value)) walkAst(child, visit, childAncestors); +} + +// For each call to `callee`, whether it runs only on the cache-miss branch of +// an `if ($[n] !== dep)` block rather than on every render. +function compilerCachedCalls(file: string, callee: string): boolean[] { + const result = compileLikeMetro(file, [], { ast: true }); + const cached: boolean[] = []; + if (typeof result !== "object" || result === null || !("ast" in result)) return cached; + walkAst(result.ast, (node, ancestors) => { + if (node.type !== "CallExpression" || !isAstNode(node.callee)) return; + if (node.callee.type !== "Identifier" || node.callee.name !== callee) return; + cached.push( + ancestors.some( + (ancestor, index) => + ancestor.type === "IfStatement" && + ancestors[index + 1] === ancestor.consequent && + JSON.stringify(ancestor.test).includes('"name":"$"'), + ), + ); + }); + return cached; +} + describe("React Compiler coverage", () => { it("Metro's transformer applies the compiler", async () => { const { transformer } = await metroConfig; @@ -137,6 +183,14 @@ describe("React Compiler coverage", () => { } }); + // The compiler once silently dropped this cache, so every + // ThreadFeed render rebuilt the feed and handed LegendList a new data array. + it("caches the thread feed derivation", () => { + expect( + compilerCachedCalls("src/features/threads/ThreadFeed.tsx", "deriveThreadFeedPresentation"), + ).toEqual([true]); + }); + it("lists only files that exist", () => { expect(Object.keys(KNOWN_BAILOUTS).filter((file) => !sourceFiles.includes(file))).toEqual([]); }); diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 8fc51ff74abb0..640deb7f6bc27 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -6,6 +6,7 @@ const reactTests = [ "src/features/settings/SettingsServerControlsRouteScreen.test.tsx", "src/features/settings/components/BranchNamingSettings.test.tsx", "src/native/NativeHeaderToolbar.test.tsx", + "src/native/useRestingTopOffset.test.tsx", "src/state/use-selected-thread-git-actions.test.tsx", ]; @@ -20,7 +21,10 @@ export default defineConfig({ name: "unit", include: ["**/*.test.{ts,tsx,mjs}"], exclude: ["**/node_modules/**", ...reactTests], - setupFiles: ["../../packages/shared/src/testing/longTempDir.ts"], + setupFiles: [ + "../../packages/shared/src/testing/longTempDir.ts", + "../../packages/shared/src/testing/providerEnv.ts", + ], }, }, { @@ -30,6 +34,7 @@ export default defineConfig({ include: reactTests, // Native components mock React Native and render through react-dom in these tests. environment: "jsdom", + setupFiles: ["../../packages/shared/src/testing/providerEnv.ts"], }, }, ], diff --git a/apps/server/package.json b/apps/server/package.json index 00bc8e66b018e..a66c4234cfdf6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,7 @@ "type": "module", "scripts": { "dev": "node --watch --watch-path=src --watch-path=../../packages --watch-path=package.json src/bin.ts", + "dev:built": "vp run dev", "build:bundle": "vp pack", "build:exe": "node scripts/cli.ts build-exe", "start": "node dist/bin.mjs", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index fca0adeb690cd..b8b47c2b2e878 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -11,6 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-executable-imports.ts"; +import rootPackageJson from "../../../package.json" with { type: "json" }; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { ServerCliBuildAssetMissingError, @@ -149,7 +150,7 @@ const buildExeCmd = Command.make( }), ).pipe( Command.withDescription( - "Build the server as a Node single-executable (needs a Node 25.7+ host for --build-sea). The binary still resolves native packages from a node_modules tree beside it.", + `Build the server as a Node single-executable. Native builds require Node ${rootPackageJson.engines.node}; explicit cross-target builds need a Node 25.7+ host for --build-sea. The binary still resolves native packages from a node_modules tree beside it.`, ), ); diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index f527d81af9867..0fed31650be51 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -43,6 +43,13 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio ["fork-thread", "project-kept", "completed", forkPayload, "2026-08-02"], ["running-thread", "project-kept", "running", "{}", "2026-08-05"], ["settled-thread", "project-kept", "completed", '{"settledAt":"2026-08-01"}', "2026-08-05"], + [ + "archived-thread", + "project-kept", + "completed", + '{"archivedAt":"2026-08-01"}', + "2026-08-05", + ], [ "limit-thread", "project-kept", @@ -73,7 +80,7 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio VALUES ('session-shared', 'running-thread'), ('session-shared', 'stopped-thread')`; yield* sql`INSERT INTO orchestration_v2_projection_context_transfers (context_transfer_id, source_thread_id, target_thread_id, type, status, updated_at, payload_json) - VALUES ('transfer-1', 'settled-thread', 'stopped-thread', 'provider_handoff', 'completed', '2026-08-01', '{}')`; + VALUES ('transfer-1', 'archived-thread', 'stopped-thread', 'provider_handoff', 'completed', '2026-08-01', '{}')`; yield* sql`INSERT INTO scheduled_tasks (task_id, title, prompt, enabled, schedule_json, project_id, workspace_strategy_json, model_selection_json, runtime_mode, interaction_mode, created_by, creation_source, @@ -88,7 +95,7 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio }); it.layer(NodeServices.layer)("migrate-dev-db", (it) => { - it.effect("keeps stopped thread families from live projects and clears pending work", () => + it.effect("keeps stopped and settled thread families and clears pending work", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -97,7 +104,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { const source = yield* createFixtureSource(sourceDir); const result = yield* runMigrateDevDb( - { baseDir: destDir, source, projects: 5, threadsPerProject: 1 }, + { baseDir: destDir, source, projects: 5, threadsPerProject: 2 }, { sharedHome: sourceDir }, ); @@ -122,11 +129,11 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { ); assert.deepStrictEqual( kept.threads.map((row) => row.thread_id), - ["fork-thread", "stopped-thread"], + ["fork-thread", "settled-thread", "stopped-thread"], ); assert.deepStrictEqual( kept.events.map((row) => row.stream_id), - ["fork-thread", "stopped-thread"], + ["fork-thread", "settled-thread", "stopped-thread"], ); assert.deepStrictEqual( kept.sessions.map((row) => row.provider_session_id), @@ -136,7 +143,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { }), ); - it.effect("never copies a settled thread's rows, and new events append after the source's", () => + it.effect("never copies an archived thread, and new events append after the source's", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-" }); @@ -160,21 +167,48 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { result.databasePath, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const [settledRows] = yield* sql<{ count: number }>` + const [archivedRows] = yield* sql<{ count: number }>` SELECT - (SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'settled-thread') - + (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'settled-thread') + (SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'archived-thread') + + (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'archived-thread') AS count`; const [sequence] = yield* sql<{ seq: number }>` SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`; - return { settledRows: settledRows?.count, sequence: sequence?.seq }; + return { archivedRows: archivedRows?.count, sequence: sequence?.seq }; }), ); - assert.equal(copied.settledRows, 0); + assert.equal(copied.archivedRows, 0); assert.equal(copied.sequence, sourceSequence?.seq); }), ); + it.effect("skips a thread family larger than the per-project limit", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-family-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-family-dest-" }); + const source = yield* createFixtureSource(sourceDir); + + // stopped-thread and its fork are 2 threads; settled-thread is 1. + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 1 }, + { sharedHome: sourceDir }, + ); + const threads = yield* withDatabase( + result.databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ thread_id: string }>` + SELECT thread_id FROM orchestration_v2_projection_threads`; + }), + ); + assert.deepStrictEqual( + threads.map((row) => row.thread_id), + ["settled-thread"], + ); + }), + ); + it.effect("upgrades a source from before the V2 thread tables", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 71cd0ced16d8d..2a559927176b7 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -217,12 +217,11 @@ const CLEARED_TABLES: ReadonlyArray = [ const PROVIDER_SESSIONS_TABLE = "orchestration_v2_projection_provider_sessions"; +// Settled threads stay: they are most of real history, and the UI shows them. const rootIsVisible = (sql: SqlClient.SqlClient) => sql` root.deleted_at IS NULL AND json_extract(root.payload_json, '$.deletedAt') IS NULL - AND json_extract(root.payload_json, '$.archivedAt') IS NULL - AND json_extract(root.payload_json, '$.settledAt') IS NULL - AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled'`; + AND json_extract(root.payload_json, '$.archivedAt') IS NULL`; /** * One transaction reads the source, so the copy is consistent while the real server writes. @@ -309,7 +308,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra FROM orchestration_v2_projection_threads`; yield* sql`CREATE TEMP TABLE stopped_families AS - SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at + SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at, COUNT(*) AS size FROM thread_families f JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id WHERE ${rootIsVisible(sql)} @@ -341,6 +340,9 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra ) AS recency_rank FROM stopped_families sf JOIN kept_projects kp ON kp.project_id = sf.project_id + -- One orchestrator family can hold hundreds of subagents and most of + -- the database, so a family larger than the limit is skipped. + WHERE sf.size <= ${input.threadsPerProject} ) WHERE recency_rank <= ${input.threadsPerProject} )`; @@ -570,7 +572,7 @@ export const migrateDevDbCommand = Command.make( threadsPerProject: Flag.Int("threads-per-project").pipe( Flag.withDefault(10), Flag.withDescription( - "How many recent stopped threads, with their forks and subagents, to keep per project.", + "How many recent stopped threads, with their forks and subagents, to keep per project. A family with more threads than this is skipped.", ), ), baseDir: Flag.String("base-dir").pipe( diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index a5d0a2f6616b4..05e6e9c3b1b83 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -17,6 +17,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http"; import { vi } from "vite-plus/test"; @@ -359,6 +360,47 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + // Resolving `file://attacker.test/share/cat.jpg` from an agent's markdown sent the + // Windows user's NTLM credentials to attacker.test. + it.effect("never resolves a network media path outside the thread's project", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved: Array = []; + const recordingFileSystem = FileSystem.FileSystem.of({ + ...fs, + realPath: (requestedPath) => { + resolved.push(requestedPath); + return Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: requestedPath, + }), + ); + }, + }); + const share = "//nas.test/share/project"; + for (const resource of [ + { + _tag: "media-file", + threadId: ThreadId.make("thread-1"), + path: "//attacker.test/s/a.png", + }, + { _tag: "draft-workspace-file", cwd: share, path: "//attacker.test/s/a.png" }, + { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: `${share}/a.png` }, + ] as const) { + const error = yield* issueAssetUrl({ resource, workspaceRoot: share }).pipe( + Effect.provideService(FileSystem.FileSystem, recordingFileSystem), + Effect.flip, + ); + expect(error._tag).toBe("AssetWorkspaceAssetNotFoundError"); + } + expect(resolved).toEqual([path.resolve(`${share}/a.png`)]); + }).pipe(Effect.provide(layerTest)), + ); + it.effect.skipIf(!symlinksSupported)( "rejects non-previewable files, disguised targets, and directories", () => diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c25dce2494f26..853c07435b363 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -36,6 +36,7 @@ import { isProjectFaviconPath, PROJECT_FAVICON_FALLBACK_MARKER, } from "@t3tools/shared/projectFavicon"; +import { isNetworkPath } from "@t3tools/shared/path"; import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; @@ -60,6 +61,7 @@ import * as ServerConfig from "../config.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; +import { isWithinRoot } from "../workspace/KnownWorkspaces.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { extractIcnsPng } from "./icns.ts"; @@ -349,11 +351,27 @@ const readImageDimensionsFromHeader = (filePath: string) => const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMediaFileAsset")( function* (input: { readonly requestedPath: string; + readonly workspaceRoot: string | undefined; readonly resource: AssetResource; readonly expiresAt: number; }) { const path = yield* Path.Path; - const canonicalFile = yield* resolveCanonicalFile(input.requestedPath).pipe( + // Resolving a network path makes Windows sign in to its host with the + // user's NTLM credentials, so only the thread's own project may be on a share. + // Windows reads a `\\?\` path as written, `..` included, so the lookup gets + // the resolved path this check approved. + const requestedPath = path.resolve(input.requestedPath); + if ( + isNetworkPath(input.requestedPath) && + (input.workspaceRoot === undefined || + !isWithinRoot(path, requestedPath, path.resolve(input.workspaceRoot))) + ) { + return yield* new AssetWorkspaceAssetNotFoundError({ + resource: input.resource, + checkedPath: input.requestedPath, + }); + } + const canonicalFile = yield* resolveCanonicalFile(requestedPath).pipe( Effect.mapError( (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), ), @@ -524,6 +542,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } const finalized = yield* finalizeAbsoluteMediaFileAsset({ requestedPath, + workspaceRoot: input.workspaceRoot, resource: input.resource, expiresAt, }); @@ -566,6 +585,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i if (path.isAbsolute(hostPath)) { const finalized = yield* finalizeAbsoluteMediaFileAsset({ requestedPath: hostPath, + workspaceRoot: draftWorkspaceRoot, resource: input.resource, expiresAt, }); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 5fed022e81136..9a55bbbc47332 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -625,7 +625,11 @@ export class EnvironmentAuth extends Context.Service< /** T3-CUSTOM(expbkt3): the approving grant or session held `access:write`. */ readonly operator?: boolean; }) => Effect.Effect< - { readonly token: string; readonly expiresAt: DateTime.DateTime }, + { + readonly sessionId: AuthSessionId; + readonly token: string; + readonly expiresAt: DateTime.DateTime; + }, ServerAuthInternalError >; /** @@ -1432,7 +1436,11 @@ export const make = Effect.gen(function* () { client: { ...input.client, label: input.label, deviceType: "bot" }, }) .pipe( - Effect.map((issued) => ({ token: issued.token, expiresAt: issued.expiresAt })), + Effect.map((issued) => ({ + sessionId: issued.sessionId, + token: issued.token, + expiresAt: issued.expiresAt, + })), Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), Effect.withSpan("EnvironmentAuth.issueMcpClientSession"), ); diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index b0a2ceffef8f6..2f7c24536caaa 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -18,6 +18,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { extendConnectionAttemptTimeout, keepOutboundFetchOnHttp1 } from "./outboundHttp.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; +import { threadCommand } from "./cli/thread.ts"; import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { updateCommand } from "./cli/update.ts"; import { uninstallCommand } from "./cli/uninstall.ts"; @@ -76,6 +77,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => authCommand, browserCommand, projectCommand, + threadCommand, serviceCommand, updateCommand, uninstallCommand, diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 1eeeee4aaba27..1dd8c2a9597fd 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -9,6 +9,7 @@ import { AuthStandardClientScopes } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, expect, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; @@ -287,6 +288,46 @@ describe("t3 pair", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + // regression: #10516 minting opens the target database with this checkout's + // migrations, so a worktree must never fall back to the live install. + it.effect("does not fall back to the shared home from a linked worktree", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-worktree-test-")); + const worktree = NodePath.join(root, "worktree"); + NodeFS.mkdirSync(worktree); + NodeFS.writeFileSync( + NodePath.join(worktree, ".git"), + `gitdir: ${NodePath.join(root, "repo", ".git", "worktrees", "worktree")}\n`, + ); + const sharedHome = NodePath.join(root, "shared"); + yield* persistServerRuntimeState({ + path: NodePath.join(sharedHome, "userdata", "server-runtime.json"), + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: Number(new URL(origin).port), + }), + }); + + const error = yield* provideCliTestLayers( + runCli(["pair"]).pipe( + Effect.provideService(HostProcess.WorkingDirectory, worktree), + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_HOME: sharedHome } })), + ), + Effect.flip, + ), + ); + + assert.include(String(error), "No running T3 Code server found."); + assert.notInclude(String(error), sharedHome); + assert.deepEqual(NodeFS.readdirSync(NodePath.join(sharedHome, "userdata")), [ + "server-runtime.json", + ]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + // Without the capability `tailscale serve` blocks on an enablement URL, so // pairing must stop at status instead of spawning it. it.effect("explains a tailnet without HTTPS serve instead of running tailscale serve", () => diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index b37b951adbb35..2b366a3be8ec7 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -5,6 +5,7 @@ import { PortSchema, } from "@t3tools/contracts"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { @@ -61,12 +62,16 @@ export class NoRunningServerError extends Schema.TaggedError ` checked ${statePath}`), + ...(this.inLinkedWorktree + ? ["In a linked worktree, pass --base-dir to pair with a server outside it."] + : []), "Start one with `npx t3 serve`, or connect this machine with T3 Connect: `npx t3 connect`.", ].join("\n"); } @@ -228,18 +233,22 @@ const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( explicitBaseDir: string | undefined, ) { const bases: Array = []; + let inLinkedWorktree = false; if (explicitBaseDir !== undefined && explicitBaseDir.trim().length > 0) { bases.push(yield* resolveBaseDir(explicitBaseDir)); } else { - // Same precedence as dev-runner: inside a linked worktree its own `.t3` - // outranks the shared home, so `t3 pair` in a worktree pairs with the dev - // server under test rather than the daily-driver install. - const worktreeHome = yield* resolveWorktreeT3Home(process.cwd()); + // Same precedence as dev-runner: inside a linked worktree only its own + // `.t3` counts. Minting opens the target database with this checkout's + // migrations, so falling back to the shared home could migrate the live + // install with unreleased schema. + const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcess.WorkingDirectory); if (worktreeHome !== undefined) { bases.push(worktreeHome); + inLinkedWorktree = true; + } else { + const envHome = yield* Config.String("T3CODE_HOME").pipe(Config.option); + bases.push(yield* resolveBaseDir(Option.getOrUndefined(envHome))); } - const envHome = yield* Config.String("T3CODE_HOME").pipe(Config.option); - bases.push(yield* resolveBaseDir(Option.getOrUndefined(envHome))); } const checkedStatePaths: Array = []; @@ -274,7 +283,7 @@ const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( } satisfies DiscoveredPairTarget; } } - return yield* new NoRunningServerError({ checkedStatePaths }); + return yield* new NoRunningServerError({ checkedStatePaths, inLinkedWorktree }); }); /** diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index e9f0d7e19ae12..fee427cc7fa28 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -13,6 +13,13 @@ import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } fr const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); +// The desktop launcher reads this process's stdout and stderr. Once it is gone, +// every log write fails, and Node turns an unhandled stream error into a crash +// that skips shutdown and leaves the ownership lock and runtime record behind. +function ignoreClosedLauncherPipe(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE" && error.code !== "ERR_STREAM_DESTROYED") throw error; +} + const runServerCommand = Effect.fn("server.runServerCommand")(function* ( flags: CliServerFlags, options?: { @@ -25,6 +32,10 @@ const runServerCommand = Effect.fn("server.runServerCommand")(function* ( const config = yield* resolveServerConfig(flags, logLevel, options); const run = runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config)); if (config.mode !== "desktop" || config.desktopBootstrapToken === undefined) return yield* run; + yield* Effect.sync(() => { + process.stdout.on("error", ignoreClosedLauncherPipe); + process.stderr.on("error", ignoreClosedLauncherPipe); + }); return yield* Effect.raceFirst( run, awaitLauncherLoss().pipe( diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts new file mode 100644 index 0000000000000..c2e4bc5c8168c --- /dev/null +++ b/apps/server/src/cli/thread.test.ts @@ -0,0 +1,75 @@ +import { assert, it } from "@effect/vitest"; +import { RunId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { type ThreadTools, waitForRun } from "./thread.ts"; + +const threadId = ThreadId.make("thread-1"); +const runId = RunId.make("run-1"); + +/** Answers each tool call with the next scripted response for that tool. */ +const scriptedTools = (script: Record>) => { + const calls: Array<{ readonly tool: string; readonly args: unknown }> = []; + const call = ((tool: string, args: unknown) => { + calls.push({ tool, args }); + const next = script[tool]?.shift(); + return next === undefined ? Effect.die(`unscripted ${tool} call`) : Effect.succeed(next); + }) as ThreadTools["call"]; + return { tools: { call } satisfies ThreadTools, calls }; +}; + +it.effect("--wait follows the sent run to its continuation's terminal status", () => + Effect.gen(function* () { + const continuation = RunId.make("run-2"); + const { tools, calls } = scriptedTools({ + t3_thread_wait: [ + { threadId, runId, status: "running", timedOut: true }, + { threadId, runId: continuation, status: "failed", timedOut: false }, + ], + t3_thread_read: [{ thread: { pendingRequestCount: 0 } }], + }); + + const outcome = yield* waitForRun(tools, threadId, runId); + + assert.deepStrictEqual(outcome, { type: "ended", runId: continuation, status: "failed" }); + assert.deepStrictEqual( + calls.filter((entry) => entry.tool === "t3_thread_wait").map((entry) => entry.args), + [ + { threadId, runId, timeoutMs: 15_000 }, + { threadId, runId, timeoutMs: 15_000 }, + ], + ); + }), +); + +it.effect("--wait stops when the thread waits for an answer instead of polling forever", () => + Effect.gen(function* () { + const { tools } = scriptedTools({ + t3_thread_wait: [ + { threadId, runId, status: "queued", timedOut: true }, + { threadId, runId, status: "queued", timedOut: true }, + ], + t3_thread_read: [{ thread: { pendingRequestCount: 1 } }], + }); + + assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId), { type: "needs_input" }); + }), +); + +it.effect("--wait reports its run's result when a later run is the one waiting", () => + Effect.gen(function* () { + const { tools } = scriptedTools({ + t3_thread_wait: [ + { threadId, runId, status: "running", timedOut: true }, + { threadId, runId, status: "completed", timedOut: false }, + ], + t3_thread_read: [{ thread: { pendingRequestCount: 1 } }], + }); + + assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId), { + type: "ended", + runId, + status: "completed", + }); + }), +); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts new file mode 100644 index 0000000000000..989028afccb9d --- /dev/null +++ b/apps/server/src/cli/thread.ts @@ -0,0 +1,710 @@ +import { + OrchestratorMcpFailure, + OrchestratorMcpThreadListResult, + OrchestratorMcpThreadReadResult, + OrchestratorMcpThreadSendResult, + OrchestratorMcpThreadWaitResult, + ProjectId, + RunId, + ThreadId, + TrimmedNonEmptyString, + type OrchestratorMcpThreadListItem, + type OrchestratorMcpThreadStatus, + type OrchestratorMcpThreadTimelineItem, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as References from "effect/References"; +import * as Runtime from "effect/Runtime"; +import * as Schema from "effect/Schema"; +import * as Stdio from "effect/Stdio"; +import * as Stream from "effect/Stream"; +import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { mcpToolSession, type McpToolSession } from "../mcp/AcpMcpStdioBridge.ts"; +import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +/** + * Short enough that a run which stops to ask the user something is noticed + * soon, since `t3_thread_wait` only returns on a terminal run. + */ +const WAIT_SLICE_MS = 15_000; +const READ_PAGE_LIMIT = 100; +const TITLE_MAX_CHARS = 80; + +export class ThreadServerNotRunningError extends Schema.TaggedError()( + "ThreadServerNotRunningError", + { baseDir: Schema.String }, +) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + return `No running T3 Code server uses ${this.baseDir}. Start T3 Code, then retry.`; + } +} + +export class ThreadServerUnreachableError extends Schema.TaggedError()( + "ThreadServerUnreachableError", + { origin: Schema.String, cause: Schema.Defect() }, +) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + const reason = this.cause instanceof Error ? ` ${this.cause.message}` : ""; + return `Could not reach the T3 Code server at ${this.origin}.${reason}`; + } +} + +export class ThreadToolFailedError extends Schema.TaggedError()( + "ThreadToolFailedError", + { tool: Schema.String, detail: Schema.String }, +) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + return this.detail; + } +} + +export class ThreadUsageError extends Schema.TaggedError()("ThreadUsageError", { + detail: Schema.String, +}) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + return this.detail; + } +} + +export class ThreadProjectNotFoundError extends Schema.TaggedError()( + "ThreadProjectNotFoundError", + { identifier: Schema.String }, +) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + return `No T3 Code project matches '${this.identifier}'. Pass a project id or a path inside a project.`; + } +} + +/** Exit code 2 lets a script tell "needs a human" apart from a failed run. */ +export class ThreadNeedsInputError extends Schema.TaggedError()( + "ThreadNeedsInputError", + { threadId: ThreadId }, +) { + override readonly [Runtime.errorReported] = false; + override readonly [Runtime.errorExitCode] = 2; + + override get message(): string { + return `Thread ${this.threadId} is waiting for an answer. Open it in T3 Code to respond.`; + } +} + +export class ThreadRunEndedError extends Schema.TaggedError()( + "ThreadRunEndedError", + { threadId: ThreadId, status: Schema.String, error: Schema.optional(Schema.String) }, +) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + const reason = this.error === undefined ? "." : `: ${this.error}`; + return `Thread ${this.threadId} stopped with status ${this.status}${reason}`; + } +} + +const ThreadCliError = Schema.Union([ + ThreadServerNotRunningError, + ThreadServerUnreachableError, + ThreadToolFailedError, + ThreadUsageError, + ThreadProjectNotFoundError, + ThreadNeedsInputError, + ThreadRunEndedError, +]); +const isThreadCliError = Schema.is(ThreadCliError); + +/** + * These errors are expected outcomes for a script, so they print as one line + * on stderr instead of the runtime's logged stack trace. + */ +const reportThreadErrors = (effect: Effect.Effect) => + effect.pipe( + Effect.tapError((error) => + isThreadCliError(error) ? Console.error(error.message) : Effect.void, + ), + ); + +const decodeFailureText = Schema.decodeUnknownOption(Schema.fromJsonString(OrchestratorMcpFailure)); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const printJson = (value: unknown) => Console.log(encodeJson(value)); + +const ToolResult = Schema.Struct({ + isError: Schema.optional(Schema.Boolean), + structuredContent: Schema.optional(Schema.Unknown), + content: Schema.optional( + Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) })), + ), +}); + +const decodeToolResult = Schema.decodeUnknownEffect(ToolResult); + +const ProjectListResult = Schema.Struct({ + projects: Schema.Array( + Schema.Struct({ id: ProjectId, title: Schema.String, workspaceRoot: Schema.String }), + ), + nextCursor: Schema.NullOr(Schema.Int), +}); + +const ThreadLaunchResult = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + runId: Schema.NullOr(RunId), +}); + +const toolFailureDetail = (result: typeof ToolResult.Type) => { + const text = result.content?.find((part) => part.text !== undefined)?.text ?? ""; + return Option.match(decodeFailureText(text), { + onNone: () => text || "The tool call failed.", + onSome: (failure) => failure.message, + }); +}; + +const makeThreadTools = (session: McpToolSession, origin: string) => { + const unexpected = (tool: string) => + new ThreadToolFailedError({ + tool, + detail: `The server sent an unexpected ${tool} result. Update the t3 CLI to match the server.`, + }); + return { + call: Effect.fn("cli.thread.callTool")(function* >( + tool: string, + args: Readonly>, + schema: S, + ): Effect.fn.Return { + const raw = yield* session + .call(tool, args) + .pipe(Effect.mapError((cause) => new ThreadServerUnreachableError({ origin, cause }))); + const result = yield* decodeToolResult(raw).pipe(Effect.mapError(() => unexpected(tool))); + if (result.isError === true) { + return yield* new ThreadToolFailedError({ tool, detail: toolFailureDetail(result) }); + } + return yield* Schema.decodeUnknownEffect(schema)(result.structuredContent).pipe( + Effect.mapError(() => unexpected(tool)), + ); + }), + }; +}; +export type ThreadTools = ReturnType; + +/** + * Signs in as a short-lived MCP client of the running server, the same way an + * outside agent does, and revokes that session when the command ends. + */ +const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* ( + flags: { readonly baseDir: Option.Option }, + run: (tools: ThreadTools) => Effect.Effect, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* Effect.gen(function* () { + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return yield* new ThreadServerNotRunningError({ baseDir: config.baseDir }); + } + const origin = runtimeState.value.origin; + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const issued = yield* Effect.acquireRelease( + environmentAuth.issueMcpClientSession({ + label: "t3 thread cli", + // The CLI runs as the machine's owner. New threads still start at + // the project's default mode, never at this ceiling by default. + access: "full-access", + client: { deviceType: "bot" }, + }), + (session) => + environmentAuth.revokeSession(session.sessionId).pipe(Effect.ignore({ log: true })), + ); + const session = yield* mcpToolSession({ + endpoint: new URL("/mcp", origin).href, + authorization: `Bearer ${issued.token}`, + clientName: "t3-thread-cli", + }).pipe(Effect.mapError((cause) => new ThreadServerUnreachableError({ origin, cause }))); + return yield* run(makeThreadTools(session, origin)); + }).pipe( + Effect.scoped, + Effect.provide( + EnvironmentAuth.layerRuntime.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)), + ), + ), + ); +}); + +const readMessageArgument = Effect.fn("cli.thread.readMessage")(function* (argument: string) { + const text = + argument === "-" + ? yield* Stdio.Stdio.pipe( + Effect.flatMap((stdio) => stdio.stdin.pipe(Stream.decodeText(), Stream.mkString)), + ) + : argument; + if (text.trim().length === 0) { + return yield* new ThreadUsageError({ detail: "The message is empty." }); + } + return text; +}); + +const listAllProjects = Effect.fn("cli.thread.listAllProjects")(function* (tools: ThreadTools) { + const projects: Array<(typeof ProjectListResult.Type)["projects"][number]> = []; + let cursor: number | null = 0; + while (cursor !== null) { + const page: typeof ProjectListResult.Type = yield* tools.call( + "t3_project_list", + { cursor, limit: 100 }, + ProjectListResult, + ); + projects.push(...page.projects); + cursor = page.nextCursor; + } + return projects; +}); + +/** A project id, or a path inside a project's folder (deepest project wins). */ +const resolveProject = Effect.fn("cli.thread.resolveProject")(function* ( + tools: ThreadTools, + identifier: string, +) { + const projects = yield* listAllProjects(tools); + const byId = projects.find((project) => project.id === identifier); + if (byId !== undefined) return byId; + + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + // Stored roots keep the path the project was added with, which may pass + // through a symlink (macOS /tmp), so both sides compare as real paths. + const realPath = (value: string) => + fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); + const target = yield* realPath(path.resolve(yield* HostProcess.WorkingDirectory, identifier)); + const roots = yield* Effect.forEach(projects, (project) => + Effect.map(realPath(project.workspaceRoot), (root) => ({ project, root })), + ); + const containing = roots + .filter(({ root }) => { + const relative = path.relative(root, target); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); + }) + .toSorted((left, right) => right.root.length - left.root.length)[0]; + if (containing === undefined) { + return yield* new ThreadProjectNotFoundError({ identifier }); + } + return containing.project; +}); + +/** Every item on a timeline view, oldest first. */ +const readTimeline = Effect.fn("cli.thread.readTimeline")(function* ( + tools: ThreadTools, + threadId: ThreadId, + view: "messages" | "activity", +) { + const items: Array = []; + let afterPosition: number | undefined; + let page: OrchestratorMcpThreadReadResult; + do { + page = yield* tools.call( + "t3_thread_read", + { + threadId, + view, + limit: READ_PAGE_LIMIT, + runLimit: 1, + ...(afterPosition === undefined ? {} : { afterPosition }), + }, + OrchestratorMcpThreadReadResult, + ); + items.push(...page.items); + afterPosition = page.nextPosition ?? undefined; + } while (page.hasMore && afterPosition !== undefined); + return { thread: page.thread, items }; +}); + +/** Item text past the read's per-item cap. */ +const readFullText = Effect.fn("cli.thread.readFullText")(function* ( + tools: ThreadTools, + threadId: ThreadId, + item: OrchestratorMcpThreadTimelineItem, +) { + let text = item.text ?? ""; + let nextTextOffset = item.textTruncated ? (item.nextTextOffset ?? null) : null; + while (nextTextOffset !== null) { + const page = yield* tools.call( + "t3_thread_read", + { threadId, itemId: item.itemId, textOffset: nextTextOffset, runLimit: 1 }, + OrchestratorMcpThreadReadResult, + ); + const chunk = page.items.find((candidate) => candidate.itemId === item.itemId); + if (chunk === undefined) break; + text += chunk.text ?? ""; + nextTextOffset = chunk.textTruncated ? (chunk.nextTextOffset ?? null) : null; + } + return text; +}); + +/** The run's last assistant message, or its last error when it did not complete. */ +const runResultText = Effect.fn("cli.thread.runResultText")(function* ( + tools: ThreadTools, + threadId: ThreadId, + runId: RunId, + type: "assistant_message" | "error", +) { + const { items } = yield* readTimeline( + tools, + threadId, + type === "assistant_message" ? "messages" : "activity", + ); + const item = items.findLast((candidate) => candidate.runId === runId && candidate.type === type); + return item === undefined ? null : yield* readFullText(tools, threadId, item); +}); + +type WaitOutcome = + | { + readonly type: "ended"; + readonly runId: RunId | null; + readonly status: OrchestratorMcpThreadStatus; + } + | { readonly type: "needs_input" }; + +/** + * Waits for this exact run, following a restart to its continuation, so a + * message another client sends meanwhile cannot stand in for its result. + */ +export const waitForRun = Effect.fn("cli.thread.waitForRun")(function* ( + tools: ThreadTools, + threadId: ThreadId, + runId: RunId, +) { + const waitSlice = (timeoutMs: number) => + tools.call("t3_thread_wait", { threadId, runId, timeoutMs }, OrchestratorMcpThreadWaitResult); + const ended = (waited: OrchestratorMcpThreadWaitResult) => + ({ type: "ended", runId: waited.runId, status: waited.status }) satisfies WaitOutcome; + while (true) { + const waited = yield* waitSlice(WAIT_SLICE_MS); + if (!waited.timedOut) return ended(waited); + // A pending request on this run, or on an earlier run this one is queued + // behind, blocks it until someone answers. + const { thread } = yield* tools.call( + "t3_thread_read", + { threadId, limit: 1, runLimit: 1 }, + OrchestratorMcpThreadReadResult, + ); + if (thread.pendingRequestCount > 0) { + // The count is thread-wide: if this run ended since the slice, the + // request belongs to a later run. + const recheck = yield* waitSlice(1); + return recheck.timedOut ? ({ type: "needs_input" } satisfies WaitOutcome) : ended(recheck); + } + } +}); + +/** + * With --json the result always reaches stdout, so a script keeps the thread + * id even when the run fails. + */ +const finishRun = Effect.fn("cli.thread.finishRun")(function* ( + tools: ThreadTools, + input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly json: boolean; + }, +) { + const outcome = yield* waitForRun(tools, input.threadId, input.runId); + const runId = outcome.type === "ended" ? (outcome.runId ?? input.runId) : input.runId; + const status = outcome.type === "ended" ? outcome.status : "waiting"; + const reply = + status === "completed" + ? yield* runResultText(tools, input.threadId, runId, "assistant_message") + : null; + const error = + status === "completed" || status === "waiting" + ? null + : yield* runResultText(tools, input.threadId, runId, "error"); + if (input.json) { + yield* printJson({ threadId: input.threadId, runId, status, reply, error }); + } else if (reply !== null) { + yield* Console.log(reply); + } + if (outcome.type === "needs_input") { + return yield* new ThreadNeedsInputError({ threadId: input.threadId }); + } + if (status !== "completed") { + return yield* new ThreadRunEndedError({ + threadId: input.threadId, + status, + ...(error === null ? {} : { error }), + }); + } +}); + +const titleFromMessage = (message: string) => { + const firstLine = message.trim().split("\n")[0]?.trim() ?? ""; + return firstLine.length > TITLE_MAX_CHARS + ? `${firstLine.slice(0, TITLE_MAX_CHARS - 1).trimEnd()}…` + : firstLine; +}; + +const launchWorkspaceStrategy = (input: { + readonly worktree: Option.Option; + readonly branch: Option.Option; +}) => + Option.match(input.worktree, { + onNone: () => + Option.isSome(input.branch) + ? Effect.fail(new ThreadUsageError({ detail: "--branch needs --worktree ." })) + : Effect.succeed(undefined), + onSome: (baseRef) => + Effect.succeed({ + type: "worktree" as const, + baseRef, + ...Option.match(input.branch, { onNone: () => ({}), onSome: (branch) => ({ branch }) }), + }), + }); + +const jsonFlag = Flag.Boolean("json").pipe( + Flag.withDescription("Print JSON instead of text."), + Flag.withDefault(false), +); +const waitFlag = Flag.Boolean("wait").pipe( + Flag.withDescription( + "Wait for the agent to finish and print its reply. Exits 1 if the run fails and 2 if it waits for an answer.", + ), + Flag.withDefault(false), +); +const messageArgument = Argument.String("message").pipe( + Argument.withDescription("Message for the agent, or - to read it from stdin."), +); +const threadArgument = Argument.String("thread").pipe(Argument.withDescription("Thread id.")); + +const decodeThreadIdValue = Schema.decodeUnknownEffect(ThreadId); +const decodeTitle = Schema.decodeUnknownEffect(TrimmedNonEmptyString); +const decodeThreadId = (value: string) => + decodeThreadIdValue(value.trim()).pipe( + Effect.mapError(() => new ThreadUsageError({ detail: `'${value}' is not a thread id.` })), + ); + +const threadListCommand = Command.make("list", { + ...projectLocationFlags, + project: Flag.String("project").pipe( + Flag.withDescription("Project id or a path inside the project. Defaults to this directory."), + Flag.withDefault("."), + ), + json: jsonFlag, +}).pipe( + Command.withDescription("List a project's threads, newest first."), + Command.withHandler((flags) => + withThreadTools( + flags, + Effect.fn("cli.thread.list")(function* (tools) { + const project = yield* resolveProject(tools, flags.project); + const threads: Array = []; + let cursor: number | null = 0; + while (cursor !== null) { + const page: OrchestratorMcpThreadListResult = yield* tools.call( + "t3_thread_list", + { projectId: project.id, includeSubagents: false, limit: 100, cursor }, + OrchestratorMcpThreadListResult, + ); + threads.push(...page.threads); + cursor = page.nextCursor; + } + if (flags.json) { + return yield* printJson(threads); + } + for (const thread of threads) { + yield* Console.log(`${thread.threadId} ${thread.status.padEnd(11)} ${thread.title}`); + } + }), + ).pipe(reportThreadErrors), + ), +); + +const threadStartCommand = Command.make("start", { + ...projectLocationFlags, + project: Argument.String("project").pipe( + Argument.withDescription("Project id or a path inside the project, such as ."), + ), + message: messageArgument, + title: Flag.String("title").pipe( + Flag.withDescription("Thread title. Defaults to the message's first line."), + Flag.optional, + ), + provider: Flag.String("provider").pipe( + Flag.withDescription("Provider instance id. Pass with --model."), + Flag.optional, + ), + model: Flag.String("model").pipe( + Flag.withDescription("Model slug. Defaults to the project's default model."), + Flag.optional, + ), + worktree: Flag.String("worktree").pipe( + Flag.withDescription( + "Run in a new git worktree based on this branch, tag, or commit, instead of the project folder.", + ), + Flag.optional, + ), + branch: Flag.String("branch").pipe( + Flag.withDescription("Branch name for the new worktree."), + Flag.optional, + ), + wait: waitFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Start a thread in a project and print its id."), + Command.withHandler((flags) => + Effect.gen(function* () { + const message = yield* readMessageArgument(flags.message); + const workspaceStrategy = yield* launchWorkspaceStrategy(flags); + if (Option.isSome(flags.provider) !== Option.isSome(flags.model)) { + return yield* new ThreadUsageError({ detail: "--provider and --model go together." }); + } + const title = yield* decodeTitle( + Option.getOrElse(flags.title, () => titleFromMessage(message)), + ).pipe(Effect.mapError(() => new ThreadUsageError({ detail: "The title is empty." }))); + yield* withThreadTools( + flags, + Effect.fn("cli.thread.start")(function* (tools) { + const project = yield* resolveProject(tools, flags.project); + const launched = yield* tools.call( + "t3_thread_launch", + { + projectId: project.id, + title, + message, + ...(workspaceStrategy === undefined ? {} : { workspaceStrategy }), + ...(Option.isSome(flags.provider) && Option.isSome(flags.model) + ? { + modelSelection: { + instanceId: flags.provider.value, + model: flags.model.value, + }, + } + : {}), + }, + ThreadLaunchResult, + ); + if (flags.wait && launched.runId !== null) { + if (!flags.json) yield* Console.error(`Thread ${launched.threadId}`); + return yield* finishRun(tools, { + threadId: launched.threadId, + runId: launched.runId, + json: flags.json, + }); + } + yield* flags.json ? printJson(launched) : Console.log(launched.threadId); + }), + ); + }).pipe(reportThreadErrors), + ), +); + +const threadSendCommand = Command.make("send", { + ...projectLocationFlags, + thread: threadArgument, + message: messageArgument, + wait: waitFlag, + json: jsonFlag, +}).pipe( + Command.withDescription( + "Send a message to a thread. A busy thread gets it as its next turn; nothing is interrupted.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const threadId = yield* decodeThreadId(flags.thread); + const message = yield* readMessageArgument(flags.message); + yield* withThreadTools( + flags, + Effect.fn("cli.thread.send")(function* (tools) { + const sent = yield* tools.call( + "t3_thread_send", + { threadId, message, mode: "queue" }, + OrchestratorMcpThreadSendResult, + ); + if (flags.wait) { + return yield* finishRun(tools, { threadId, runId: sent.runId, json: flags.json }); + } + yield* flags.json + ? printJson(sent) + : Console.log(sent.delivery === "queued" ? "Queued." : "Sent."); + }), + ); + }).pipe(reportThreadErrors), + ), +); + +const threadShowCommand = Command.make("show", { + ...projectLocationFlags, + thread: threadArgument, + messages: Flag.Int("messages").pipe( + Flag.withDescription("How many of the latest messages to print."), + Flag.withDefault(10), + ), + json: jsonFlag, +}).pipe( + Command.withDescription("Show a thread's state and its latest messages."), + Command.withHandler((flags) => + Effect.gen(function* () { + const threadId = yield* decodeThreadId(flags.thread); + yield* withThreadTools( + flags, + Effect.fn("cli.thread.show")(function* (tools) { + const { thread, items } = yield* readTimeline(tools, threadId, "messages"); + const conversation = items.filter( + (item) => item.type === "user_message" || item.type === "assistant_message", + ); + const latest = conversation.slice(Math.max(0, conversation.length - flags.messages)); + const messages = yield* Effect.forEach(latest, (item) => + readFullText(tools, threadId, item).pipe( + Effect.map((text) => ({ + role: item.type === "user_message" ? "user" : "assistant", + runId: item.runId, + text, + })), + ), + ); + if (flags.json) { + return yield* printJson({ thread, messages }); + } + yield* Console.log(thread.title); + yield* Console.log( + [thread.threadId, thread.status, `${thread.providerInstanceId}/${thread.model}`].join( + " ", + ), + ); + if (thread.worktreePath !== null) { + yield* Console.log(`${thread.branch ?? "(no branch)"} ${thread.worktreePath}`); + } + if (thread.pendingRequestCount > 0) { + yield* Console.log(`Waiting for ${thread.pendingRequestCount} answer(s).`); + } + for (const entry of messages) { + yield* Console.log(`\n[${entry.role}]\n${entry.text}`); + } + }), + ); + }).pipe(reportThreadErrors), + ), +); + +export const threadCommand = Command.make("thread").pipe( + Command.withDescription("Start, message, and read threads on the running server."), + Command.withSubcommands([ + threadListCommand, + threadStartCommand, + threadSendCommand, + threadShowCommand, + ]), +); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index f2e2e699860bf..df1ede8263ba0 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -211,7 +211,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( const baseDir = typeof baseDirOrPrefix === "string" ? baseDirOrPrefix - : yield* fs.makeTempDirectoryScoped({ prefix: baseDirOrPrefix.prefix }); + : // macOS temp dirs live under the /var -> /private/var symlink; storage cleanup's + // symlink guards compare resolved paths, so generated test dirs must be real paths. + yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: baseDirOrPrefix.prefix })); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); yield* ensureServerDirectories(derivedPaths); diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index b480b021375f3..5c981033a9953 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -15,6 +15,11 @@ import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.t import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; +// ProcessDiagnostics treats process.pid as the server, so the fixture PID is an +// offset from it. A fixed PID collides whenever the test worker is assigned it. +const SERVER_PID = process.pid; +const CHILD_PID = SERVER_PID + 1; + function makeNativeSnapshot( processes: ResourceMonitorSnapshotEvent["processes"], ): ResourceMonitorSnapshotEvent { @@ -71,7 +76,7 @@ describe("ProcessDiagnostics", () => { const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( Effect.flatMap((processDiagnostics) => processDiagnostics.signal({ - pid: 4_242, + pid: CHILD_PID, startTimeMs: 2_000, signal: "SIGINT", }), @@ -80,10 +85,12 @@ describe("ProcessDiagnostics", () => { ); expect(result).toEqual({ - pid: 4242, + pid: CHILD_PID, signal: "SIGINT", signaled: false, - message: Option.some("Process 4242 no longer matches the selected process identity."), + message: Option.some( + `Process ${CHILD_PID} no longer matches the selected process identity.`, + ), }); }), ); @@ -92,8 +99,8 @@ describe("ProcessDiagnostics", () => { Effect.gen(function* () { const snapshot = makeNativeSnapshot([ { - pid: 4_242, - ppid: process.pid, + pid: CHILD_PID, + ppid: SERVER_PID, startTimeMs: 2_000, runTimeMs: 4_000, name: "agent", @@ -134,7 +141,7 @@ describe("ProcessDiagnostics", () => { const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( Effect.flatMap((processDiagnostics) => processDiagnostics.signal({ - pid: 4_242, + pid: CHILD_PID, startTimeMs: 2_000, signal: "SIGINT", }), @@ -143,11 +150,11 @@ describe("ProcessDiagnostics", () => { ); expect(result).toEqual({ - pid: 4_242, + pid: CHILD_PID, signal: "SIGINT", signaled: false, message: Option.some( - "Could not refresh process 4242; refusing to signal a stale identity.", + `Could not refresh process ${CHILD_PID}; refusing to signal a stale identity.`, ), }); }), @@ -160,7 +167,7 @@ describe("ProcessDiagnostics", () => { ); const snapshot = makeNativeSnapshot([ { - pid: 4_242, + pid: CHILD_PID, ppid: 1, startTimeMs: 2_000, runTimeMs: 4_000, @@ -182,7 +189,7 @@ describe("ProcessDiagnostics", () => { type: "desktopTelemetry", sequence: 1, sampledAtUnixMs, - electronPid: 4_242, + electronPid: CHILD_PID, power: { source: "electron-main", idle: "false", @@ -198,7 +205,7 @@ describe("ProcessDiagnostics", () => { speedLimitPercent: Option.none(), electronProcesses: [ { - pid: 4_242, + pid: CHILD_PID, creationTimeMs: 2_000, type: "Browser", name: "electron", @@ -214,7 +221,7 @@ describe("ProcessDiagnostics", () => { const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( Effect.flatMap((processDiagnostics) => processDiagnostics.signal({ - pid: 4_242, + pid: CHILD_PID, startTimeMs: 2_000, signal: "SIGKILL", }), @@ -223,10 +230,10 @@ describe("ProcessDiagnostics", () => { ); expect(result).toEqual({ - pid: 4_242, + pid: CHILD_PID, signal: "SIGKILL", signaled: false, - message: Option.some("Process 4242 is not a signalable T3 backend descendant."), + message: Option.some(`Process ${CHILD_PID} is not a signalable T3 backend descendant.`), }); const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index b4a9bca3d8f51..b53d758536c11 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -17,6 +17,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; import { expect } from "vite-plus/test"; import type { @@ -1182,6 +1183,62 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("branch PR lookup notifies linked threads when draft status changes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/draft-status"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/draft-status"]); + const drafts = [false, true, true, false, false, true]; + const pullRequest = { + number: 1, + title: "Draft status", + url: "https://github.com/pingdotgg/codething-mvp/pull/1", + baseRefName: "main", + headRefName: "feature/draft-status", + state: "OPEN", + }; + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + ...drafts.map((isDraft) => encodeCliJson([{ ...pullRequest, isDraft }])), + encodeCliJson([ + { + ...pullRequest, + number: 2, + url: "https://github.com/pingdotgg/codething-mvp/pull/2", + state: "MERGED", + }, + ]), + ], + }, + }); + const changes = yield* manager.subscribePullRequestStateChanges; + for (const isDraft of drafts) { + const pr = yield* manager.branchPullRequest( + { cwd: repoDir, branch: "feature/draft-status" }, + { refresh: true }, + ); + expect(pr?.isDraft === true).toBe(isDraft); + } + // A different PR ends collection without waiting on a background stream consumer. + yield* manager.branchPullRequest( + { cwd: repoDir, branch: "feature/draft-status" }, + { refresh: true }, + ); + const events = yield* Stream.runCollect( + changes.pipe(Stream.takeUntil((event) => event.number === 2, { excludeLast: true })), + ); + expect(events).toEqual([ + { host: "github.com", repository: "pingdotgg/codething-mvp", number: 1 }, + { host: "github.com", repository: "pingdotgg/codething-mvp", number: 1 }, + { host: "github.com", repository: "pingdotgg/codething-mvp", number: 1 }, + ]); + }), + ); + it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1891,6 +1948,11 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { "gitlab.example.com/group/subgroup/repository", ], ["https://bitbucket.org/team/repository/pull-requests/42", "bitbucket.org/team/repository"], + ["https://codeberg.org/team/repository/pulls/42", "codeberg.org/team/repository"], + [ + "http://code.example:3000/forgejo/team/repository/pulls/42/files", + "code.example/forgejo/team/repository", + ], [ "https://dev.azure.com/org/project/_git/repository/pullrequest/42", "dev.azure.com/org/project/_git/repository", @@ -2683,6 +2745,42 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("includes CLAUDE.md for Claude writers only when it differs from AGENTS.md", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "AGENTS.md"), "Use imperative subjects.\n"); + NodeFS.copyFileSync(NodePath.join(repoDir, "AGENTS.md"), NodePath.join(repoDir, "CLAUDE.md")); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n"); + const commitInstructions: Array = []; + + const { manager } = yield* makeManager({ + serverSettings: { + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-5", + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + commitInstructions.push(input.policy?.commitInstructions); + return Effect.succeed({ subject: "Update readme", body: "" }); + }, + }, + }); + + yield* runStackedAction(manager, { cwd: repoDir, action: "commit" }); + + NodeFS.writeFileSync(NodePath.join(repoDir, "CLAUDE.md"), "Mention the Claude provider.\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello again\n"); + yield* runStackedAction(manager, { cwd: repoDir, action: "commit" }); + + expect(commitInstructions[0]).toContain("Local AGENTS.md:\nUse imperative subjects."); + expect(commitInstructions[0]).not.toContain("Local CLAUDE.md:"); + expect(commitInstructions[1]).toContain("Local CLAUDE.md:\nMention the Claude provider."); + }), + ); + it.effect("falls back when the dedicated source control writer is unavailable", () => 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 a118855cc06c6..b0980ad9e7d45 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -272,7 +272,7 @@ export function pullRequestRepositoryKey(value: string): string | null { try { const url = new URL(value); const match = - /^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( + /^(.*)(?:\/pulls?\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( url.pathname, ); if (match?.[1] === undefined) return null; @@ -910,7 +910,10 @@ export const make = Effect.gen(function* () { ? [["Recent commit subjects from this repository:", ...subjects].join("\n")] : []), ...(agentInstructions ? [`Local AGENTS.md:\n${agentInstructions}`] : []), - ...(claudeInstructions ? [`Local CLAUDE.md:\n${claudeInstructions}`] : []), + // CLAUDE.md is often a symlink or copy of AGENTS.md; skip the duplicate. + ...(claudeInstructions && claudeInstructions !== agentInstructions + ? [`Local CLAUDE.md:\n${claudeInstructions}`] + : []), ].join("\n\n"); if (!examples) { return repositoryConventionsTextGenerationPolicy; @@ -1265,21 +1268,27 @@ export const make = Effect.gen(function* () { return prLookupFailureTtl(streak); }; const pullRequestStateChanges = yield* PubSub.sliding(64); - const lookupStates = new Map(); + const lookupStates = new Map< + string, + { readonly state: PullRequestInfo["state"]; readonly isDraft: boolean } + >(); const noteLookupState = (pr: PullRequestInfo) => Effect.suspend(() => { const parsed = parseChangeRequestUrl(pr.url); if (parsed === null || parsed.number !== pr.number) return Effect.void; const key = normalizeThreadPullRequestKey(parsed); const id = threadPullRequestKeyOf(key); - const previous = lookupStates.get(id) ?? "open"; + const previous = lookupStates.get(id); lookupStates.delete(id); if (lookupStates.size >= PR_LOOKUP_CACHE_CAPACITY) { const oldest = lookupStates.keys().next().value; if (oldest !== undefined) lookupStates.delete(oldest); } - lookupStates.set(id, pr.state); - return previous === pr.state ? Effect.void : PubSub.publish(pullRequestStateChanges, key); + lookupStates.set(id, { state: pr.state, isDraft: pr.isDraft === true }); + return (previous?.state ?? "open") === pr.state && + (previous?.isDraft === true) === (pr.isDraft === true) + ? Effect.void + : PubSub.publish(pullRequestStateChanges, key); }); const prLookupCache = yield* Cache.makeWith( (key: string) => { diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts index 8811351452339..5fb08c68a55dc 100644 --- a/apps/server/src/htmlRender/HtmlRender.test.ts +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -1,11 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off - plain local servers stand in for LAN services. import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Base64 from "effect/encoding/Base64"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -19,6 +20,7 @@ import * as NodeURL from "node:url"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as HtmlRender from "./HtmlRender.ts"; +import { HtmlRenderBrowserError } from "./headlessChrome.ts"; import * as PreviewBrowser from "../preview/PreviewBrowser.ts"; const TEST_BROWSER_ENV = "T3CODE_TEST_HEADLESS_SHELL"; @@ -290,6 +292,33 @@ describe("HtmlRender", () => { }), ); + // Windows reports ENOENT asynchronously for this path instead of throwing ENOTDIR. + describe.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "when the browser spawn throws synchronously", + () => { + it.live("reports a typed preview error", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const file = yield* fileSystem.makeTempFileScoped({ prefix: "t3-browser-spawn-" }); + const outcome = yield* HtmlRender.HtmlRender.use((htmlRender) => + htmlRender.preview({ html: "

x

" }).pipe(Effect.flip), + ).pipe( + Effect.provide(layerHtmlRender(path.join(file, "chrome-headless-shell"))), + Effect.exit, + ); + assert(Exit.isSuccess(outcome), "Expected a typed preview error."); + const error = outcome.value; + expect(error).toBeInstanceOf(HtmlRenderBrowserError); + expect(error.message).toBe( + "Headless Chrome could not render the page: the browser could not be started.", + ); + expect(error).toMatchObject({ cause: { code: "ENOTDIR" } }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + }, + ); + it.live("keeps every local file but the page itself out of the browser", (ctx) => Effect.gen(function* () { const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; diff --git a/apps/server/src/htmlRender/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index 4ad26a4aa6838..d9339b9c46946 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -229,6 +229,13 @@ const launchBrowser = Effect.fnUntraced(function* (input: { (cause) => new HtmlRenderBrowserError({ reason: "the browser could not be started", cause }), ), + // Node throws some spawn errors synchronously, such as `spawn UNKNOWN` + // when policy blocks the executable; `mapError` cannot catch those. + Effect.catchDefect((cause) => + Effect.fail( + new HtmlRenderBrowserError({ reason: "the browser could not be started", cause }), + ), + ), ); let stderrTail = ""; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 3d56e8f9320ff..4536ae276c38b 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -588,6 +588,14 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( const staticDir = config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined); + if (!staticDir && config.devUrl) { + // `vp run dev --share --built` prints the pairing URL before its first + // web build lands in apps/web/dist. Reload until it does. + return HttpServerResponse.text( + '

Waiting for the first web build. This page reloads by itself.

', + { status: 503, contentType: "text/html", headers: { "Retry-After": "2" } }, + ); + } if (!staticDir) { return HttpServerResponse.text("No static directory configured and no dev URL set.", { status: 503, diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index 7338190ca56ad..2db36ce5abc28 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -6,6 +6,7 @@ import * as NodeReadline from "node:readline"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; @@ -14,7 +15,7 @@ import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpRespon * unevenly implemented. */ -class AcpMcpBridgeError extends Error { +export class AcpMcpBridgeError extends Error { readonly _tag = "AcpMcpBridgeError"; } @@ -53,94 +54,125 @@ function protocolVersionOf(entry: unknown): string | null { return typeof version === "string" && version.length > 0 ? version : null; } -export interface AcpMcpToolCallOptions { +export interface McpToolSessionOptions { readonly endpoint: string; readonly authorization: string; - readonly tool: string; - readonly arguments: Readonly>; + readonly clientName: string; readonly fetchImplementation?: (url: string, init?: RequestInit) => Promise; } +export interface McpToolSession { + /** Resolves to the JSON-RPC `result` of `tools/call`, a `CallToolResult`. */ + readonly call: ( + tool: string, + args: Readonly>, + ) => Effect.Effect; +} + /** - * This is the terminal fallback for ACP agents that accept `mcpServers` in - * `session/new` but fail to expose those tools to their model. Compliant ACP - * agents continue to use the stdio bridge above. + * One initialized MCP session over T3 Code's HTTP endpoint, terminated when + * the scope closes. Used by the ACP terminal fallback and `t3 thread`. */ -function callAcpMcpTool(options: AcpMcpToolCallOptions): Effect.Effect { +export const mcpToolSession = ( + options: McpToolSessionOptions, +): Effect.Effect => { const fetchImplementation = options.fetchImplementation ?? fetch; - return Effect.gen(function* () { - let sessionId: string | null = null; - let protocolVersion: string | null = null; + let sessionId: string | null = null; + let protocolVersion: string | null = null; + let nextId = 0; - const send = Effect.fn("AcpMcpStdioBridge.send")(function* ( - message: unknown, - ): Effect.fn.Return, AcpMcpBridgeError> { - const response = yield* Effect.tryPromise({ - try: () => - fetchImplementation(options.endpoint, { - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - authorization: options.authorization, - ...(sessionId === null ? {} : { "mcp-session-id": sessionId }), - ...(protocolVersion === null ? {} : { "mcp-protocol-version": protocolVersion }), - }, - body: JSON.stringify(message), - }), - catch: bridgeError, - }); - sessionId = response.headers.get("mcp-session-id") ?? sessionId; - if (!response.ok) { - yield* discardResponseBody(response); - return yield* Effect.fail( - new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), - ); - } - const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError)); - for (const payload of payloads) { - protocolVersion = protocolVersionOf(payload) ?? protocolVersion; - } - return payloads; - }); + const headers = () => ({ + accept: "application/json, text/event-stream", + authorization: options.authorization, + ...(sessionId === null ? {} : { "mcp-session-id": sessionId }), + ...(protocolVersion === null ? {} : { "mcp-protocol-version": protocolVersion }), + }); - const initializeId = "t3-acp-cli-initialize"; - const initialized = yield* send({ - jsonrpc: "2.0", - id: initializeId, - method: "initialize", - params: { - protocolVersion: MCP_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: "t3-code-acp-cli", version: "0.0.0" }, - }, + const send = Effect.fn("AcpMcpStdioBridge.send")(function* ( + message: unknown, + ): Effect.fn.Return, AcpMcpBridgeError> { + const response = yield* Effect.tryPromise({ + try: () => + fetchImplementation(options.endpoint, { + method: "POST", + headers: { "content-type": "application/json", ...headers() }, + body: JSON.stringify(message), + }), + catch: bridgeError, }); - const initializeResponse = initialized.find((entry) => asEnvelope(entry)?.id === initializeId); - if (initializeResponse === undefined || asEnvelope(initializeResponse)?.error !== undefined) { + sessionId = response.headers.get("mcp-session-id") ?? sessionId; + if (!response.ok) { + yield* discardResponseBody(response); return yield* Effect.fail( - new AcpMcpBridgeError("T3 Code MCP endpoint rejected initialization."), + new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), ); } - yield* send({ jsonrpc: "2.0", method: "notifications/initialized" }); + const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError)); + for (const payload of payloads) { + protocolVersion = protocolVersionOf(payload) ?? protocolVersion; + } + return payloads; + }); - const callId = "t3-acp-cli-tool-call"; - const responses = yield* send({ - jsonrpc: "2.0", - id: callId, - method: "tools/call", - params: { name: options.tool, arguments: options.arguments }, - }); - const response = responses.find((entry) => asEnvelope(entry)?.id === callId); - const envelope = asEnvelope(response); + const request = Effect.fn("AcpMcpStdioBridge.request")(function* ( + method: string, + params: unknown, + ): Effect.fn.Return { + const id = `t3-cli-${nextId++}`; + const responses = yield* send({ jsonrpc: "2.0", id, method, params }); + const envelope = asEnvelope(responses.find((entry) => asEnvelope(entry)?.id === id)); if (envelope === null || envelope.error !== undefined) { return yield* Effect.fail( new AcpMcpBridgeError( - `T3 Code MCP tool call failed${envelope?.error === undefined ? "." : `: ${JSON.stringify(envelope.error)}`}`, + `T3 Code MCP ${method} failed${envelope?.error === undefined ? "." : `: ${JSON.stringify(envelope.error)}`}`, ), ); } - return envelope.result; + return envelope; + }); + + const terminate = Effect.suspend(() => + sessionId === null + ? Effect.void + : Effect.tryPromise(() => + fetchImplementation(options.endpoint, { method: "DELETE", headers: headers() }), + ).pipe(Effect.flatMap(discardResponseBody), Effect.ignore), + ); + + // Registered before the handshake so a session the server opened is ended + // even when the handshake fails, and the handshake stays interruptible. + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => terminate); + yield* request("initialize", { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: options.clientName, version: "0.0.0" }, + }); + yield* send({ jsonrpc: "2.0", method: "notifications/initialized" }); + return { + call: (tool, args) => + request("tools/call", { name: tool, arguments: args }).pipe( + Effect.map((envelope) => envelope.result), + ), + } satisfies McpToolSession; }); +}; + +/** + * This is the terminal fallback for ACP agents that accept `mcpServers` in + * `session/new` but fail to expose those tools to their model. Compliant ACP + * agents continue to use the stdio bridge above. + */ +function callAcpMcpTool(options: { + readonly endpoint: string; + readonly authorization: string; + readonly tool: string; + readonly arguments: Readonly>; +}): Effect.Effect { + return mcpToolSession({ ...options, clientName: "t3-code-acp-cli" }).pipe( + Effect.flatMap((session) => session.call(options.tool, options.arguments)), + Effect.scoped, + ); } export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect.Effect { diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index b75482e0b818e..cbb70d902f4b6 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -382,6 +382,53 @@ it.effect("decodes null optional arguments the way it decodes omitted ones", () }).pipe(Effect.provide(layerRecordingTools(calls))); }); +it.effect( + "clamps t3_thread_read page sizes above their maximum instead of rejecting the call", + () => { + const calls: Array = []; + return Effect.gen(function* () { + const thread = { threadId: "thread-clamped-arguments" }; + // Codex never shows the model these bounds, so an over-large page must still read. + yield* callTool("t3_thread_read", { + ...thread, + limit: 150, + runLimit: 80, + maxCharsPerItem: 60_000, + }); + yield* callTool("t3_thread_read", { + ...thread, + limit: 100, + runLimit: 50, + maxCharsPerItem: 50_000, + }); + yield* callTool("t3_thread_read", { ...thread, limit: 7, runLimit: 1, maxCharsPerItem: 1 }); + + expect(calls).toEqual([ + { ...thread, limit: 100, runLimit: 50, maxCharsPerItem: 50_000 }, + { ...thread, limit: 100, runLimit: 50, maxCharsPerItem: 50_000 }, + { ...thread, limit: 7, runLimit: 1, maxCharsPerItem: 1 }, + ]); + }).pipe(Effect.provide(layerRecordingTools(calls))); + }, +); + +it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( + "still rejects a t3_thread_read %s below its minimum", + (field) => { + const calls: Array = []; + return Effect.gen(function* () { + // Zero is a different request than one item, so it fails rather than answering another question. + const rejected = yield* callTool("t3_thread_read", { + threadId: "thread-below-minimum", + [field]: 0, + }).pipe(Effect.flip); + + expect(String(rejected)).toContain("greater than or equal to 1"); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layerRecordingTools(calls))); + }, +); + it.effect( "registers the pull request toolkit and surfaces a missing capability as a tool error", () => diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 9b3c957456059..da3015f6b10f3 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -5,6 +5,7 @@ import { EnvironmentId, MessageId, NodeId, + type OrchestratorMcpCapabilitiesResult, OrchestratorMcpTarget, type OrchestrationV2ThreadShell, type ScheduledTask, @@ -1182,6 +1183,176 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + describe("listed models", () => { + const opencodeInstanceId = ProviderInstanceId.make("opencode"); + const model = (slug: string, extra: Partial = {}) => ({ + slug, + name: slug, + isCustom: false, + capabilities: null, + ...extra, + }); + const opencodeSlugs = Array.from( + { length: 12 }, + (_, index) => `oc/m${String(index + 1).padStart(2, "0")}`, + ); + const catalog: ReadonlyArray = [ + { + ...providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + }), + models: [ + model("gpt-6", { + name: "Sol", + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + description: "How hard the model thinks.", + type: "select", + options: [ + { id: "low", label: "Low", description: "Fast." }, + { id: "high", label: "High", isDefault: true }, + ], + promptInjectedValues: ["high"], + }, + { id: "fastMode", label: "Fast", type: "boolean" }, + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "build", label: "Build" }], + currentValue: "build", + }, + ], + }, + }), + model("gpt-5", { isLegacy: true }), + model("gpt-4", { isLegacy: true }), + model("gpt-3", { name: "GPT-3", isLegacy: true }), + model("my-alpha", { isCustom: true }), + ], + }, + { + ...providerSnapshot({ + instanceId: opencodeInstanceId, + driver: ProviderDriverKind.make("opencode"), + }), + models: [ + ...opencodeSlugs.map((slug) => model(slug, { isLegacy: slug === "oc/m03" })), + model("oc-custom", { isCustom: true }), + ], + }, + ]; + + const capabilitiesWith = (search?: string) => + Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.capabilities(scope, search === undefined ? {} : { search }); + }).pipe( + Effect.provide( + layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => + Effect.succeed( + parentProjection([], { instanceId: codexInstanceId, model: "gpt-3" }), + ), + }), + ServerSettings.layerTest({ + favorites: [ + { provider: codexInstanceId, model: "gpt-4" }, + { provider: opencodeInstanceId, model: "oc/m10" }, + ], + providerModelPreferences: { + [opencodeInstanceId]: { hiddenModels: ["oc/m02"], modelOrder: ["oc/m12"] }, + }, + }), + providerRegistryLayer(catalog), + adapterRegistryLayer([codexInstanceId, opencodeInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ), + ), + ), + ), + ); + + const listing = (capabilities: OrchestratorMcpCapabilitiesResult) => + capabilities.providers.map((provider) => ({ + id: provider.providerInstanceId, + models: provider.models.map((entry) => entry.id), + omitted: provider.omittedModels ?? 0, + })); + + it.effect( + "skips legacy models, lists the first 8 of large catalogs, and keeps pinned models", + () => + Effect.gen(function* () { + assert.deepEqual(listing(yield* capabilitiesWith()), [ + // gpt-4 is a favorite and gpt-3 is the caller's model; legacy gpt-5 is left out. + { id: codexInstanceId, models: ["gpt-6", "gpt-4", "gpt-3", "my-alpha"], omitted: 1 }, + { + id: opencodeInstanceId, + models: [ + "oc/m12", + "oc/m01", + "oc/m04", + "oc/m05", + "oc/m06", + "oc/m07", + "oc/m08", + "oc/m09", + "oc/m10", + "oc-custom", + ], + omitted: 2, + }, + ]); + }), + ); + + it.effect("search matches every word and caps each provider's matches", () => + Effect.gen(function* () { + assert.deepEqual(listing(yield* capabilitiesWith("GPT 5")), [ + { id: codexInstanceId, models: ["gpt-5"], omitted: 0 }, + { id: opencodeInstanceId, models: [], omitted: 0 }, + ]); + const opencode = listing(yield* capabilitiesWith("oc/m"))[1]; + assert.deepEqual(opencode, { + id: opencodeInstanceId, + models: ["oc/m12", "oc/m01", "oc/m03", "oc/m04", "oc/m05", "oc/m06", "oc/m07", "oc/m08"], + omitted: 3, + }); + }), + ); + + it.effect("describes models by id, distinct label, and option values", () => + Effect.gen(function* () { + const codex = (yield* capabilitiesWith()).providers[0]; + assert.deepEqual(codex?.models.slice(0, 4), [ + { + id: "gpt-6", + label: "Sol", + options: [ + { id: "effort", values: ["low", "high"], default: "high" }, + { id: "fastMode", values: [false, true] }, + { id: "agent", values: ["build"], default: "build" }, + ], + }, + { id: "gpt-4" }, + { id: "gpt-3" }, + { id: "my-alpha" }, + ]); + }), + ); + }); + it.effect("rejects delegation to a provider without a registered adapter", () => Effect.gen(function* () { const forkOnlyInstanceId = ProviderInstanceId.make("forkOnly"); @@ -2345,3 +2516,82 @@ describe("OrchestratorMcpService provider resolution", () => { ); }); }); + +describe("OrchestratorMcpService thread lookup", () => { + const bareId = ThreadId.make("5d0b4a3e-8c1f-4a52-9d37-2f6c7e1b9a04"); + const launched = liveThreadShell(ThreadId.make(`mcp:${bareId}`)); + const deletedLaunched = { ...launched, deletedAt: DateTime.makeUnsafe("2026-10-05T00:00:00Z") }; + const client: McpInvocationScope = { + // T3-CUSTOM(expbkt3): an unrestricted operator client, as upstream's fixture means. + principal: "external-operator", + actorUserId: null, + environmentId: EnvironmentId.make("environment:thread-lookup"), + requestNamespace: "client:thread-lookup", + thread: undefined, + client: { sessionId: "thread-lookup", label: "Claude Code", access: "full-access" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const service = (shells: ReadonlyArray, sent: Ref.Ref) => + layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.succeed(shells.find((shell) => shell.id === threadId) ?? null), + sendToThread: () => Ref.update(sent, (count) => count + 1).pipe(Effect.as({} as never)), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ), + ), + ); + const failuresFor = (shells: ReadonlyArray) => + Effect.gen(function* () { + const sent = yield* Ref.make(0); + return yield* Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const read = yield* mcp.readThread(client, { threadId: bareId }).pipe(Effect.flip); + const send = yield* mcp + .sendToThread(client, { threadId: bareId, message: "hello" }) + .pipe(Effect.flip); + return { read, send, sends: yield* Ref.get(sent) }; + }).pipe(Effect.provide(service(shells, sent))); + }); + + it.effect("points a bare uuid at the mcp: thread it belongs to", () => + Effect.gen(function* () { + const { read, send, sends } = yield* failuresFor([launched]); + for (const error of [read, send]) { + assert.equal(error.code, "thread_not_found"); + assert.include(error.message, `mcp:${bareId}`); + } + assert.equal(sends, 0); + }), + ); + + it.effect("keeps the plain message for an id that matches nothing", () => + Effect.gen(function* () { + const { read, send } = yield* failuresFor([]); + assert.equal(read.code, "thread_not_found"); + assert.equal(read.message, `Thread ${bareId} is no longer available.`); + assert.equal(send.code, "thread_not_found"); + assert.equal(send.message, `Thread ${bareId} was not found.`); + }), + ); + + it.effect("does not name an mcp: thread that was deleted", () => + Effect.gen(function* () { + const { read, send } = yield* failuresFor([deletedLaunched]); + assert.equal(read.message, `Thread ${bareId} is no longer available.`); + assert.equal(send.message, `Thread ${bareId} was not found.`); + }), + ); +}); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 251955c3b37e9..73f6d1c28e57f 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -20,7 +20,9 @@ import { type OrchestrationV2ThreadLaunchWorkspaceStrategy, type OrchestrationV2TurnItem, OrchestratorMcpFailure, + type OrchestratorMcpCapabilitiesInput, type OrchestratorMcpCapabilitiesResult, + type OrchestratorMcpModelOption, type OrchestratorMcpCreateThreadsInput, type OrchestratorMcpCreateThreadsResult, type OrchestratorMcpCreatedThread, @@ -66,9 +68,11 @@ import { type ScheduledTask, type ScheduledTaskUpsertInput, type ServerProvider, + type ServerProviderModel, ThreadId, isOrchestrationV2WorkActive, } from "@t3tools/contracts"; +import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { normalizeThreadPullRequestKey, @@ -151,6 +155,7 @@ type TerminalTaskStatus = Extract< export interface OrchestratorMcpServiceShape { readonly capabilities: ( scope: McpInvocationScope, + input?: OrchestratorMcpCapabilitiesInput, ) => Effect.Effect; readonly delegateTask: ( scope: McpInvocationScope, @@ -247,6 +252,26 @@ export const isLiveCallerShell = ( ) ?? false)); +/** + * Threads an agent starts over MCP are `mcp:`, and agents often paste the + * bare uuid. The id is never rewritten, so permission checks keyed on it still + * hold; the miss only names a live thread the caller could read by its full id. + */ +export const threadNotFound = Effect.fn("OrchestratorMcpService.threadNotFound")(function* ( + threads: Pick, + threadId: ThreadId, + message: string, +) { + const prefixed = ThreadId.make(`mcp:${threadId}`); + const shell = yield* threads.getThreadShell(prefixed).pipe(Effect.orElseSucceed(() => null)); + return yield* failure( + "thread_not_found", + shell === null || shell.deletedAt !== null + ? message + : `${message} Did you mean \`${prefixed}\`?`, + ); +}); + function threadManagementFailure(error: unknown): OrchestratorMcpFailure { if (!isThreadManagementError(error)) return failure("orchestration_error", errorMessage(error)); switch (error._tag) { @@ -298,6 +323,61 @@ function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): Orchestrato }; } +/** + * Large catalogs (OpenCode, Cursor, Pi) can list hundreds of models, so agents + * get a short list by default and search for the rest. These providers have + * small official catalogs, so all their current models are listed. + */ +const FULL_CATALOG_DRIVERS: ReadonlySet = new Set(["codex", "claudeAgent", "muse", "grok"]); +const LISTED_MODEL_LIMIT = 8; + +/** + * Picks the models orchestrator_capabilities shows for one provider. Hidden + * models never appear. With search terms, lists matches; otherwise lists pinned + * models (custom, favorite, the caller's own) plus the provider's non-legacy + * models, or only the first few in the user's model order for large catalogs. + */ +function listCapabilityModels(input: { + readonly provider: ServerProvider; + readonly preferences: + | { readonly hiddenModels: ReadonlyArray; readonly modelOrder: ReadonlyArray } + | undefined; + readonly isPinned: (model: ServerProviderModel) => boolean; + readonly searchTerms: ReadonlyArray; +}) { + const rank = new Map(input.preferences?.modelOrder.map((slug, index) => [slug, index])); + const visible = input.provider.models + .filter((model) => model.isCustom || !input.preferences?.hiddenModels.includes(model.slug)) + .toSorted((a, b) => (rank.get(a.slug) ?? rank.size) - (rank.get(b.slug) ?? rank.size)); + if (input.searchTerms.length > 0) { + const matches = visible.filter((model) => { + const text = `${model.slug} ${model.name} ${model.subProvider ?? ""}`.toLowerCase(); + return input.searchTerms.every((term) => text.includes(term)); + }); + const listed = matches.slice(0, LISTED_MODEL_LIMIT); + return { listed, omitted: matches.length - listed.length }; + } + const current = visible.filter((model) => !model.isLegacy); + const shown = new Set( + FULL_CATALOG_DRIVERS.has(input.provider.driver) + ? current + : current.slice(0, LISTED_MODEL_LIMIT), + ); + const listed = visible.filter((model) => input.isPinned(model) || shown.has(model)); + return { listed, omitted: visible.length - listed.length }; +} + +/** Option ids and allowed values are all a delegating agent needs; labels are UI copy. */ +function capabilityModelOption(descriptor: ProviderOptionDescriptor): OrchestratorMcpModelOption { + const defaultValue = getProviderOptionCurrentValue(descriptor); + return { + id: descriptor.id, + values: + descriptor.type === "boolean" ? [false, true] : descriptor.options.map((choice) => choice.id), + ...(defaultValue === undefined ? {} : { default: defaultValue }), + }; +} + function providerConstraints( provider: ServerProvider | undefined, supportsOrchestrationV2: boolean, @@ -923,7 +1003,9 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null { case "web_search": return jsonText({ patterns: item.patterns, results: item.results }); case "approval_request": - return item.prompt ?? item.requestKind; + return [item.prompt ?? item.requestKind, item.reason] + .filter((line): line is string => line !== undefined) + .join("\n"); case "checkpoint": return jsonText(item.files); case "run_interrupt_request": @@ -1175,7 +1257,7 @@ const make = Effect.gen(function* () { .getThreadShell(threadId) .pipe(Effect.mapError(threadManagementFailure)); if (shell === null || shell.deletedAt !== null) { - return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); + return yield* threadNotFound(threadManagement, threadId, `Thread ${threadId} was not found.`); } return yield* loadProjectThread(shell.projectId, threadId); }); @@ -1254,7 +1336,11 @@ const make = Effect.gen(function* () { .getThreadShell(threadId) .pipe(Effect.mapError(threadManagementFailure)); if (shell === null || shell.deletedAt !== null) { - return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`); + return yield* threadNotFound( + threadManagement, + threadId, + `Thread ${threadId} is no longer available.`, + ); } const target = yield* threadManagement .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [ @@ -2067,19 +2153,20 @@ const make = Effect.gen(function* () { return { status, secretRef: secretRef.value }; }), - capabilities: Effect.fn("OrchestratorMcpService.capabilities")(function* (scope) { + capabilities: Effect.fn("OrchestratorMcpService.capabilities")(function* (scope, input = {}) { const { parent, limits } = yield* loadCaller(scope); const providers = yield* loadProviders; const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); - const modelPreferences = yield* serverSettings.getSettings.pipe( - Effect.map((settings) => settings.providerModelPreferences ?? {}), + const settings = yield* serverSettings.getSettings.pipe( Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), ); + const inherited = parent?.thread.modelSelection; + const searchTerms = input.search?.toLowerCase().split(/\s+/) ?? []; return { parentThreadId: parent?.thread.id ?? null, - inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null, - inheritedModel: parent?.thread.modelSelection.model ?? null, - inheritedOptions: parent?.thread.modelSelection.options ?? [], + inheritedProviderInstanceId: inherited?.instanceId ?? null, + inheritedModel: inherited?.model ?? null, + inheritedOptions: inherited?.options ?? [], runtimeMode: limits.runtimeMode, interactionMode: limits.interactionMode, providers: providers.map((provider) => { @@ -2087,24 +2174,33 @@ const make = Effect.gen(function* () { provider, orchestrationCapableInstanceIds.has(provider.instanceId), ); + const { listed, omitted } = listCapabilityModels({ + provider, + preferences: settings.providerModelPreferences?.[provider.instanceId], + searchTerms, + isPinned: (model) => + model.isCustom || + (inherited?.instanceId === provider.instanceId && inherited.model === model.slug) || + (settings.favorites ?? []).some( + (favorite) => + favorite.provider === provider.instanceId && favorite.model === model.slug, + ), + }); return { providerInstanceId: provider.instanceId, driverKind: provider.driver, - displayName: provider?.displayName ?? null, - models: - provider?.models - .filter( - (model) => - model.isCustom || - !modelPreferences[provider.instanceId]?.hiddenModels.includes(model.slug), - ) - .map((model) => ({ - id: model.slug, - label: model.name ?? null, - ...(model.capabilities?.optionDescriptors === undefined - ? {} - : { options: model.capabilities.optionDescriptors }), - })) ?? [], + displayName: provider.displayName ?? null, + models: listed.map((model) => { + const options = model.capabilities?.optionDescriptors ?? []; + return { + id: model.slug, + ...(model.name.toLowerCase() === model.slug.toLowerCase() + ? {} + : { label: model.name }), + ...(options.length === 0 ? {} : { options: options.map(capabilityModelOption) }), + }; + }), + ...(omitted === 0 ? {} : { omittedModels: omitted }), ...(provider.supportedRuntimeModes === undefined || provider.supportedRuntimeModes.length === 0 ? {} diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index 414d9e00b5018..47a13b43675dd 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -19,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { threadNotFound } from "./OrchestratorMcpService.ts"; export class ThreadMetadataMcpService extends Context.Service< ThreadMetadataMcpService, @@ -176,7 +177,7 @@ const make = Effect.gen(function* () { ), ); if (shell === null || shell.deletedAt !== null) { - return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); + return yield* threadNotFound(threadManagement, threadId, `Thread ${threadId} was not found.`); } const target = yield* threadManagement .getProjectThreadRecords({ projectId: shell.projectId, threadId }, []) diff --git a/apps/server/src/mcp/threadAccess.test.ts b/apps/server/src/mcp/threadAccess.test.ts new file mode 100644 index 0000000000000..b38672115849b --- /dev/null +++ b/apps/server/src/mcp/threadAccess.test.ts @@ -0,0 +1,52 @@ +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import { liveThreadShell } from "./McpToolAccess.testkit.ts"; +import { readThread } from "./threadAccess.ts"; + +const bareId = ThreadId.make("5d0b4a3e-8c1f-4a52-9d37-2f6c7e1b9a04"); +const client: McpInvocationContext.McpInvocationScope = { + // T3-CUSTOM(expbkt3): an unrestricted operator client, as upstream's fixture means. + principal: "external-operator", + actorUserId: null, + environmentId: EnvironmentId.make("environment:thread-access"), + requestNamespace: "client:thread-access", + thread: undefined, + client: { sessionId: "thread-access", label: "Claude Code", access: "full-access" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, +}; + +const readBare = (existing: ReadonlyArray) => + readThread(bareId).pipe( + Effect.flip, + Effect.provideService(McpInvocationContext.McpInvocationContext, client), + Effect.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.succeed(existing.includes(threadId) ? liveThreadShell(threadId) : null), + }), + ), + ); + +it.effect("points a bare uuid at the mcp: thread it belongs to", () => + Effect.gen(function* () { + const error = yield* readBare([ThreadId.make(`mcp:${bareId}`)]); + + expect(error.code).toBe("thread_not_found"); + expect(error.message).toBe(`The thread was not found. Did you mean \`mcp:${bareId}\`?`); + }), +); + +it.effect("keeps the plain message for an id that matches nothing", () => + Effect.gen(function* () { + const error = yield* readBare([]); + + expect(error.code).toBe("thread_not_found"); + expect(error.message).toBe("The thread was not found."); + }), +); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 3726ba74fb2a5..5ce21ae66d0f3 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -172,10 +172,11 @@ export const readThread = Effect.fn("mcp.readThread")(function* < ); const shell = yield* context.threads.getThreadShell(targetId).pipe(Effect.mapError(unavailable)); if (shell === null || shell.deletedAt !== null) { - return yield* new OrchestratorMcpFailure({ - code: "thread_not_found", - message: "The thread was not found.", - }); + return yield* OrchestrationMcp.threadNotFound( + context.threads, + targetId, + "The thread was not found.", + ); } const projection = yield* context.threads .getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, { diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 51a7934f7908c..d1c40d104f477 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -11,6 +11,7 @@ import { ProviderDriverKind, ProviderInstanceId, RunId, + RuntimeRequestId, ThreadId, TurnItemId, type OrchestrationV2ProjectedTurnItem, @@ -128,6 +129,85 @@ it.effect("checks capability through the production registration", () => ), ); +it.effect("an answered question reports already answered instead of not found", () => { + const shell = McpToolAccessTestkit.liveThreadShell(threadId); + const requestId = RuntimeRequestId.make("answered-question"); + const projection = McpToolAccessTestkit.idleThreadProjection(shell); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const respond = (id: string) => + server + .callTool({ + name: "t3_pending_request_respond", + arguments: { requestId: id, answers: { size: "large" } }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(declaredFailure(yield* respond(requestId))).toMatchObject({ + code: "invalid_request", + message: "The user-input request was already answered.", + }); + expect(declaredFailure(yield* respond("missing-question"))).toMatchObject({ + code: "invalid_request", + message: "The pending user-input request was not found.", + }); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(shell), + getProjectThreadRecords: () => + Effect.succeed({ + ...projection, + runtimeRequests: [ + { + id: requestId, + nodeId: NodeId.make("root"), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "resolved", + responseCapability: { type: "message" }, + createdAt: shell.createdAt, + resolvedAt: shell.updatedAt, + answers: { size: "large" }, + }, + ], + turnItems: [ + { + id: TurnItemId.make("answered-question-item"), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: null, + startedAt: shell.createdAt, + completedAt: shell.updatedAt, + updatedAt: shell.updatedAt, + type: "user_input_request", + requestId, + questions: [], + }, + ], + }), + dispatch: () => Effect.die("an answered question must not dispatch again"), + }), + ), + ), + ), + ); +}); + it("bounds public command rejections and redacts internal dispatch causes", () => { const command = { commandId: CommandId.make("mcp-core-command"), commandType: "thread.settle" }; expect( diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 7c05b330047cf..0df604a850110 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -9,11 +9,11 @@ import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; const handlers = { - orchestrator_capabilities: McpToolAccess.reads(() => + orchestrator_capabilities: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.capabilities(scope); + return yield* service.capabilities(scope, input); }), ), delegate_task: McpToolAccess.actsAsCaller((input) => diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts new file mode 100644 index 0000000000000..75db3e4d59786 --- /dev/null +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "@effect/vitest"; +import { Tool } from "effect/ai"; + +import { OrchestratorToolkit } from "./tools.ts"; + +// Oversized page sizes clamp on decode, so the schema has no `maximum`; Codex also drops +// bounds from its TypeScript view. The description is the only place a model sees a limit. +it("states t3_thread_read page size limits in the parameter descriptions", () => { + const schema = Tool.getJsonSchema(OrchestratorToolkit.tools.t3_thread_read) as { + readonly properties: Readonly>; + }; + const limits = { limit: "100", runLimit: "50", maxCharsPerItem: "50000" }; + for (const [name, maximum] of Object.entries(limits)) { + expect(schema.properties[name]?.description, name).toContain(maximum); + } +}); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 9c24fb62d8e93..567cb92727326 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpCapabilitiesInput, OrchestratorMcpCapabilitiesResult, OrchestrationV2StopWorkflowInput, OrchestratorMcpCreateThreadsInput, @@ -52,7 +53,8 @@ const threadMetadataDependencies = [ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { description: - "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this caller. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + "List the V2 provider instances, a short list of their models from the same live catalog as the composer, inherited runtime settings, and app-owned orchestration features available to this caller. The list has the user's favorite and custom models, your own model, every current Codex, Claude, Muse and Grok model, and the first 8 current models of other providers. Other legacy models are left out. omittedModels counts models left out; pass search, such as 'sonnet 4.6', to find them. delegate_task accepts any catalog model, listed or not. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + parameters: OrchestratorMcpCapabilitiesInput, success: OrchestratorMcpCapabilitiesResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -116,7 +118,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", + "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. A path segment ending in [] fans out over an array: {path:'body.issue.labels[].name', op:'equals', value:'needs more info'} matches when any label has that name, and notEquals only when none does. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", parameters: OrchestratorMcpScheduleTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index e4fb0ecfc5f63..f8b3e8b8fb256 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -4,6 +4,8 @@ import { ProjectId, ProviderInstanceId, type Project as ProjectRecord, + type ModelSelection, + type RuntimeMode, } from "@t3tools/contracts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -28,7 +30,10 @@ import * as ProjectHandlers from "./handlers.ts"; import { ProjectToolkit } from "./tools.ts"; const clientLaunchHarness = (input: { - readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; + readonly runtimeModeCeiling: RuntimeMode; + readonly defaultRuntimeMode?: RuntimeMode; + /** Where Settings saves a project's model; the legacy project field stays null. */ + readonly overrideModel?: ModelSelection; readonly launched: Array; readonly workspaceRoot?: string; readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; @@ -39,7 +44,19 @@ const clientLaunchHarness = (input: { model: "claude-opus", }; const layerDependencies = Layer.mergeAll( - ServerSettings.layerTest(), + ServerSettings.layerTest({ + ...(input.defaultRuntimeMode === undefined + ? {} + : { defaultRuntimeMode: input.defaultRuntimeMode }), + ...(input.overrideModel === undefined + ? {} + : { + projectSettingsFolded: true, + projectSettingsOverrides: { + [projectId]: { defaultModelSelection: input.overrideModel }, + }, + }), + }), NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { principal: "external-operator", @@ -81,7 +98,7 @@ const clientLaunchHarness = (input: { ? Option.some({ id, workspaceRoot: input.workspaceRoot ?? "/projects/client-target", - defaultModelSelection: modelSelection, + defaultModelSelection: input.overrideModel === undefined ? modelSelection : null, } as unknown as ProjectRecord) : Option.none(), ), @@ -101,35 +118,85 @@ const clientLaunchHarness = (input: { return { projectId, modelSelection, dependencies: layerDependencies }; }; -it.effect("a client launches at its ceiling with the project's default model", () => - Effect.gen(function* () { - const launched: Array = []; - const { projectId, modelSelection, dependencies } = clientLaunchHarness({ - runtimeModeCeiling: "auto-accept-edits", - launched, - }); - const toolkit = yield* ProjectToolkit.pipe( - Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), - ), - ); - const handle = (params: Parameters>[1]) => - toolkit - .handle("t3_thread_launch", params) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); +it.effect( + "a client launches with the project's default model and mode, capped at its ceiling", + () => + Effect.gen(function* () { + const launchWith = Effect.fn("test.launchWith")(function* ( + runtimeModeCeiling: RuntimeMode, + defaultRuntimeMode: RuntimeMode, + params: { + readonly title: string; + readonly message?: string; + readonly runtimeMode?: RuntimeMode; + readonly targeted?: false; + readonly overrideModel?: ModelSelection; + }, + ) { + const { targeted, overrideModel, ...input } = params; + const launched: Array = []; + const { projectId, modelSelection, dependencies } = clientLaunchHarness({ + runtimeModeCeiling, + defaultRuntimeMode, + launched, + ...(overrideModel === undefined ? {} : { overrideModel }), + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(dependencies), + ), + ), + ); + const result = yield* toolkit + .handle("t3_thread_launch", targeted === false ? input : { ...input, projectId }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + return { result: result.at(-1)?.result, launched, projectId, modelSelection }; + }); - const result = yield* handle({ title: "Fix", projectId, message: "Fix the bug" }); - expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); - expect(launched[0]?.runtimeMode).toBe("auto-accept-edits"); - expect(launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); + const belowCeiling = yield* launchWith("full-access", "approval-required", { + title: "Fix", + message: "Fix the bug", + }); + expect(belowCeiling.result).toMatchObject({ + projectId: belowCeiling.projectId, + modelSelection: belowCeiling.modelSelection, + }); + expect(belowCeiling.launched[0]?.runtimeMode).toBe("approval-required"); + expect(belowCeiling.launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); - const escalated = yield* handle({ title: "Fix", projectId, runtimeMode: "full-access" }); - expect(escalated.at(-1)?.result).toMatchObject({ code: "runtime_mode_escalation_denied" }); + const aboveCeiling = yield* launchWith("auto-accept-edits", "full-access", { title: "Fix" }); + expect(aboveCeiling.launched[0]?.runtimeMode).toBe("auto-accept-edits"); - const untargeted = yield* handle({ title: "Fix" }); - expect(untargeted.at(-1)?.result).toMatchObject({ code: "target_required" }); - expect(launched).toHaveLength(1); - }), + const requested = yield* launchWith("full-access", "approval-required", { + title: "Fix", + runtimeMode: "auto", + }); + expect(requested.launched[0]?.runtimeMode).toBe("auto"); + + const escalated = yield* launchWith("auto-accept-edits", "auto-accept-edits", { + title: "Fix", + runtimeMode: "full-access", + }); + expect(escalated.result).toMatchObject({ code: "runtime_mode_escalation_denied" }); + expect(escalated.launched).toHaveLength(0); + + const untargeted = yield* launchWith("full-access", "full-access", { + title: "Fix", + targeted: false, + }); + expect(untargeted.result).toMatchObject({ code: "target_required" }); + + const overrideModel = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-settings", + }; + const fromSettings = yield* launchWith("full-access", "full-access", { + title: "Fix", + overrideModel, + }); + expect(fromSettings.launched[0]?.modelSelection).toEqual(overrideModel); + }), ); it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () => diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 8984729ca61e4..ccba14c96b94d 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -13,6 +13,9 @@ import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -132,10 +135,26 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }), ), ); + // A caller outside a thread has nothing to inherit, so it starts with the + // project's effective defaults, as the composer does. Its mode stays + // within the ceiling it was approved with. + const projectDefaults = + caller === undefined + ? yield* ServerSettings.ServerSettingsService.pipe( + Effect.flatMap((settings) => settings.getSettings), + Effect.mapError(unavailable), + Effect.flatMap((settings) => + Effect.map( + readProject, + (project) => resolveProjectSettings(settings, projectId, project).settings, + ), + ), + ) + : undefined; const modelSelection = input.modelSelection ?? caller?.modelSelection ?? - (yield* readProject)?.defaultModelSelection ?? + projectDefaults?.defaultModelSelection ?? undefined; if (modelSelection === undefined) return yield* new OrchestratorMcpFailure({ @@ -143,13 +162,19 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { message: "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", }); + const startedRuntimeMode = + projectDefaults !== undefined && input.runtimeMode === undefined + ? yield* resolveRuntimeMode(runtimeMode, projectDefaults.defaultRuntimeMode).pipe( + Effect.orElseSucceed(() => runtimeMode), + ) + : runtimeMode; const result = yield* ThreadMessageIntake.launchThread({ commandId, threadId, projectId, title: input.title, modelSelection, - runtimeMode, + runtimeMode: startedRuntimeMode, interactionMode, workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, // T3-CUSTOM(expbkt3): BK sidebar custom group for the new thread. diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 9404c51f443b0..43340e6c2a7b7 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -59,10 +59,7 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: { }) { const context = yield* readThread(input.threadId, ["runtimeRequests", "turnItems"]); const request = context.projection.runtimeRequests.find( - (request) => - request.id === input.requestId && - request.kind === "user_input" && - request.status === "pending", + (request) => request.id === input.requestId && request.kind === "user_input", ); const item = context.projection.turnItems.find( (item) => item.type === "user_input_request" && item.requestId === input.requestId, @@ -72,6 +69,14 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: { code: "invalid_request", message: "The pending user-input request was not found.", }); + if (request.status !== "pending") + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + request.status === "resolved" + ? "The user-input request was already answered." + : `The user-input request is no longer pending (${request.status}).`, + }); return { ...context, request, item }; }); const writesThread =

( diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index 6632625b6ba38..6ad819131e956 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -124,6 +124,18 @@ export const serverEventLoopDelayMax = Metric.gauge("t3_server_event_loop_delay_ attributes: { unit: "ms" }, }); +export const runsTotal = Metric.counter("t3_runs_total", { + description: "Runs that finished, by outcome. A later rollback does not count again.", +}); + +export const runDuration = Metric.timer("t3_run_duration", { + description: "Wall time of a run, from when it started to its terminal status.", +}); + +export const runQueueWait = Metric.timer("t3_run_queue_wait", { + description: "Time a run waited between being requested and starting.", +}); + export const gitCommandsTotal = Metric.counter("t3_git_commands_total", { description: "Total git commands executed by the server runtime.", }); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 58871bfdd3a55..16d657bc192c6 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -96,6 +96,23 @@ describe("AntigravityAdapterV2 flavor", () => { ), ); }); + + it("keeps the question card to the agent's own options so a typed answer cannot be sent", () => { + const question = flavor.extractPermissionQuestion?.( + permissionRequest("interaction_2", [ + { optionId: "opt-main", name: "main", kind: "allow_once" }, + { optionId: "opt-dev", name: "dev", kind: "allow_once" }, + ]), + ); + assert.strictEqual(question?.question.allowCustomAnswer, false); + assert.deepEqual( + question?.question.options.map((option) => option.value), + ["opt-main", "opt-dev"], + ); + assert.deepEqual(question?.respond({ interaction_2: "opt-dev" }), { + outcome: { outcome: "selected", optionId: "opt-dev" }, + }); + }); }); const layerSession = Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index fb7130ed16392..1ea491b76b71c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -198,16 +198,7 @@ export function makeAntigravityAcpAdapterFlavor( const question = extractAntigravityUserInputQuestion(request); if (question === undefined) return undefined; return { - question: { - id: question.id, - header: question.header, - question: question.question, - options: question.options.map((option) => ({ - label: option.label, - description: option.description, - })), - multiSelect: false, - }, + question, respond: (answers) => makeAntigravityUserInputResponse(request, answers), }; }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index fb9e40f377447..ce692ef0fb7cf 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -254,6 +254,28 @@ describe("ClaudeAdapterV2 runtime query policy", () => { assert.isUndefined(options.extraArgs?.["dangerously-skip-permissions"]); }); + it("gives delegated children Claude's subagent prompt cache TTL", () => { + const lineage = (relationshipToParent: "fork" | "subagent" | null) => ({ + lineage: { + parentThreadId: null, + relationshipToParent, + rootThreadId: ThreadId.make("thread-root"), + }, + }); + const env = { PATH: "/bin" }; + const ttl = (environment: NodeJS.ProcessEnv, ...args: Parameters) => + ClaudeAdapterV2.claudeQueryEnvironment(environment, lineage(...args), "5m") + .CLAUDE_CODE_PROMPT_CACHE_TTL; + assert.equal(ttl(env, "subagent"), "5m"); + assert.isUndefined(ttl(env, null)); + assert.isUndefined(ttl(env, "fork")); + assert.equal(ttl({ ...env, CLAUDE_CODE_PROMPT_CACHE_TTL: "1h" }, "subagent"), "1h"); + assert.strictEqual( + ClaudeAdapterV2.claudeQueryEnvironment(env, lineage("subagent"), undefined), + env, + ); + }); + it("projects AskUserQuestion input with question text as the answer key", () => { const preview = "\n **Proposed change**\n\n```ts\n\tconst enabled = true;\n```\n "; assert.deepEqual( @@ -588,6 +610,47 @@ describe("ClaudeAdapterV2 context usage", () => { }); }); +describe("ClaudeAdapterV2 approval presentation", () => { + it("marks asks that full access could not auto-allow and keeps the CLI's reason", () => { + const presentation = ClaudeAdapterV2.claudeApprovalPresentation({ + permissionMode: "bypassPermissions", + prompt: 'Claude wants to run rm -r -- "$rr/$h"', + callbackOptions: { + decisionReason: "\u001b[33mDangerous rm on a path built from a variable\u001b[39m", + suppressAlwaysAllowRule: true, + }, + }); + assert.deepEqual(presentation, { + reason: "Dangerous rm on a path built from a variable", + fullAccessOverride: "provider_check", + options: [ + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, + ], + }); + }); + + it("attributes a bypass ask to the user's ask rule when one matched", () => { + const presentation = ClaudeAdapterV2.claudeApprovalPresentation({ + permissionMode: "bypassPermissions", + prompt: "Claude wants to run git push", + callbackOptions: { + matchedAskRule: { source: "userSettings", toolName: "Bash", ruleContent: "git push:*" }, + }, + }); + assert.deepEqual(presentation, { fullAccessOverride: "ask_rule" }); + }); + + it("adds nothing to ordinary approvals outside full access", () => { + const presentation = ClaudeAdapterV2.claudeApprovalPresentation({ + permissionMode: "default", + prompt: "Run make?", + callbackOptions: { decisionReason: "Run make?" }, + }); + assert.deepEqual(presentation, {}); + }); +}); + describe("ClaudeAdapterV2 session permissions", () => { // regression: #15892 an explicit user refusal must stay user_reject, not a tool failure it("keeps explicit user refusals classified as user_reject", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index faf468713e496..269a119eb0606 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -67,6 +67,7 @@ import type { } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; // @effect-diagnostics-next-line nodeBuiltinImport:off -- the SDK takes a Node ChildProcess for the CLI. import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { AgentScope } from "@t3tools/shared/AgentScope"; @@ -103,6 +104,8 @@ import { type OrchestrationV2TurnItem, type OrchestrationV2WebSearchResult, type ProviderApprovalDecision, + type ProviderApprovalFullAccessOverride, + type ProviderApprovalOption, ProviderDriverKind, type ProviderInstanceId, type ProviderRequestKind, @@ -110,6 +113,7 @@ import { type ProviderThreadId, type RunId, type ThreadId, + resolveIdleCompaction, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -146,6 +150,7 @@ import { type CompiledClaudeModelSelection, } from "../../claudeModelOptions.ts"; import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; @@ -1260,6 +1265,24 @@ function claudeLiveSelectionFlagSettings( }; } +// A delegated child is its own SDK session, so Claude Code gives it the main-conversation cache +// TTL. When the user sets Claude's subagentPromptCacheTtl, the child follows it like a native +// subagent would. An explicit CLAUDE_CODE_PROMPT_CACHE_TTL on the instance still wins. +export function claudeQueryEnvironment( + environment: NodeJS.ProcessEnv, + appThread: Pick, + subagentPromptCacheTtl: "5m" | "1h" | undefined, +): NodeJS.ProcessEnv { + if ( + appThread.lineage.relationshipToParent !== "subagent" || + subagentPromptCacheTtl === undefined || + environment.CLAUDE_CODE_PROMPT_CACHE_TTL !== undefined + ) { + return environment; + } + return { ...environment, CLAUDE_CODE_PROMPT_CACHE_TTL: subagentPromptCacheTtl }; +} + export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; readonly nativeThreadId: string; @@ -2169,6 +2192,17 @@ const decodeClaudeBypassSettings = Schema.decodeUnknownEffect( fromLenientJson(ClaudeBypassSettings), ); +const ClaudePromptCacheTtl = Schema.Literals(["5m", "1h"]); +const decodeClaudePromptCacheTtl = Schema.decodeUnknownOption(ClaudePromptCacheTtl); +const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( + fromLenientJson( + Schema.StructWithRest( + Schema.Struct({ subagentPromptCacheTtl: Schema.optionalKey(ClaudePromptCacheTtl) }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), +); + function claudeBypassAvailability( settings: typeof ClaudeBypassSettings.Type, ): ClaudeBypassAvailability { @@ -2850,6 +2884,45 @@ function parentToolUseIdFromSdkMessage(message: SDKMessage): string | null { : null; } +const CLAUDE_NO_STANDING_GRANT_OPTIONS = [ + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, +] satisfies ReadonlyArray; + +export function claudeApprovalPresentation(input: { + readonly permissionMode: PermissionMode; + readonly prompt: string; + readonly callbackOptions: Pick< + Parameters[2], + "decisionReason" | "matchedAskRule" | "suppressAlwaysAllowRule" + >; +}): { + readonly reason?: string; + readonly fullAccessOverride?: ProviderApprovalFullAccessOverride; + readonly options?: ReadonlyArray; +} { + // The reason can carry terminal escapes from the CLI's own prompt renderer. + const reason = NodeUtil.stripVTControlCharacters( + input.callbackOptions.decisionReason ?? "", + ).trim(); + // Bypass auto-allows everything it can, so any ask that still reaches the + // callback is one bypass refused. The SDK drops the CLI's decision_reason_type, + // leaving the matched ask rule as the only way to tell the two causes apart. + const fullAccessOverride: ProviderApprovalFullAccessOverride | undefined = + input.permissionMode !== "bypassPermissions" + ? undefined + : input.callbackOptions.matchedAskRule === undefined + ? "provider_check" + : "ask_rule"; + return { + ...(reason === "" || reason === input.prompt ? {} : { reason }), + ...(fullAccessOverride === undefined ? {} : { fullAccessOverride }), + ...(input.callbackOptions.suppressAlwaysAllowRule === true + ? { options: CLAUDE_NO_STANDING_GRANT_OPTIONS } + : {}), + }; +} + export function permissionResultFromDecision(input: { readonly toolName: string; readonly decision: ProviderApprovalDecision; @@ -3867,6 +3940,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( adapterOptions: ClaudeAdapterV2Options, ) { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const serverSettings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; const subagentModelCatalog = scopeClaudeModelCatalog( BUNDLED_CLAUDE_MODEL_CATALOG, @@ -3914,6 +3988,30 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, ); + // Claude Code's own subagent TTL setting, in its precedence order. Unset or unreadable means + // Anthropic's default applies. + const resolveSubagentPromptCacheTtl = Effect.fn("ClaudeAdapterV2.resolveSubagentPromptCacheTtl")( + function* (cwd: string | null) { + const fromEnvironment = decodeClaudePromptCacheTtl( + adapterOptions.environment.CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL, + ); + if (Option.isSome(fromEnvironment)) return fromEnvironment.value; + const resolved = yield* Effect.tryPromise(() => + resolveClaudeSettings({ + ...(cwd === null ? {} : { cwd }), + settingSources: customHome ? ["project", "local"] : ["user", "project", "local"], + }), + ).pipe(Effect.orElseSucceed(() => undefined)); + const home = customHome + ? yield* fileSystem.readFileString(path.join(homePath, "settings.json")).pipe( + Effect.flatMap(decodeClaudeSubagentCacheSettings), + Effect.orElseSucceed(() => ({ subagentPromptCacheTtl: undefined })), + ) + : undefined; + return resolved?.effective.subagentPromptCacheTtl ?? home?.subagentPromptCacheTtl; + }, + ); + const userInvocableSkills = (cwd: string | null) => discoverClaudeSkills( adapterOptions.settings, @@ -6359,6 +6457,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly nativeRequestId: string; readonly requestKind: OrchestrationV2RuntimeRequest["kind"]; readonly prompt?: string; + readonly presentation?: ReturnType; readonly questions?: ReadonlyArray; }) { const createdAt = yield* DateTime.now; @@ -6443,6 +6542,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( requestId, requestKind: input.requestKind as ProviderRequestKind, ...(input.prompt === undefined ? {} : { prompt: input.prompt }), + ...input.presentation, } : { type: "user_input_request" as const, @@ -9027,6 +9127,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeRequestId, requestKind, prompt, + presentation: claudeApprovalPresentation({ + permissionMode: context.queryPolicy.permissionMode, + prompt, + callbackOptions, + }), }); const decision = yield* Deferred.make(); yield* Ref.update(pendingRuntimeRequests, (current) => { @@ -9096,6 +9201,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (request.dialogKind !== "resume_return") { return { behavior: "cancelled" as const }; } + const idleCompaction = Option.isSome(serverSettings) + ? yield* serverSettings.value.getSettings.pipe( + Effect.map((settings) => + resolveIdleCompaction(settings, adapterOptions.instanceId), + ), + Effect.orElseSucceed(() => adapterOptions.settings.idleCompaction), + ) + : adapterOptions.settings.idleCompaction; + if (idleCompaction === "off") { + return { behavior: "completed" as const, result: "continue" as const }; + } const ageMinutes = typeof request.payload.sessionAgeMinutes === "number" && Number.isFinite(request.payload.sessionAgeMinutes) @@ -9328,7 +9444,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( cwd: turnInput.runtimePolicy.cwd, attachmentsDir, settings: adapterOptions.settings, - environment: providerEnvironment, // T3-CUSTOM(expbkt3): replacement queries receive the current credential. + environment: claudeQueryEnvironment( + // T3-CUSTOM(expbkt3): replacement queries receive the current credential. + providerEnvironment, + turnInput.appThread, + turnInput.appThread.lineage.relationshipToParent === "subagent" + ? yield* resolveSubagentPromptCacheTtl(turnInput.runtimePolicy.cwd) + : undefined, + ), tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, ...(mcpOverrides.allowedTools === undefined ? {} diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 72e8b1d079fca..3375294b4c9e7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -6105,5 +6105,34 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ), ); + + it.effect("keeps every path of a completed multi-file patch", () => + Effect.gen(function* () { + const { harness } = yield* startApprovalTurn("file-change-multi-path", [ + fileChange("started", nativeTurnId, "patch-a", [renameC, deleteE, addA]), + fileChange("completed", nativeTurnId, "patch-a", [renameC, deleteE, addA]), + turnCompleted(nativeTurnId), + ]); + yield* harness.firstTerminal; + + const fileChanges = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "file_change" + ? [event.turnItem] + : [], + ); + assert.lengthOf(fileChanges, 1); + assert.equal(fileChanges[0]?.fileName, "/w/c.ts"); + assert.deepEqual(fileChanges[0]?.changes, [ + { operation: "update", path: "/w/d.ts", oldPath: "/w/c.ts" }, + { operation: "delete", path: "/w/e.ts" }, + { operation: "add", path: "/w/a.ts" }, + ]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 2aa409bfcfc45..0a5ed47688195 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -3668,6 +3668,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( type: "file_change", fileName: firstChange.path, diffStr: firstChange.diff, + changes: item.changes.map((change) => { + const movePath = change.kind.type === "update" ? change.kind.move_path : undefined; + return movePath + ? { operation: change.kind.type, path: movePath, oldPath: change.path } + : { operation: change.kind.type, path: change.path }; + }), }; return { node, turnItem }; }, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 02cfee3e7a011..c67db57f3ad77 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -514,6 +514,71 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + // OpenCode types an empty balance as provider.quota too, but answers it with + // HTTP 402 and it never resets. Usage windows and budgets arrive as 429. + it.effect.each([ + { + name: "an out-of-funds provider.quota (402)", + failureClass: "provider_error", + error: { + type: "provider.quota", + message: "Upstream request failed: Insufficient account funds", + status: 402, + }, + }, + { + name: "a provider.quota usage window (429)", + failureClass: "usage_limit", + error: { + type: "provider.quota", + message: "Subscription quota exceeded.", + status: 429, + response: { + body: JSON.stringify({ + type: "error", + error: { type: "GoUsageLimitError", message: "Subscription quota exceeded." }, + }), + }, + }, + }, + { + name: "a provider.quota account budget (429)", + failureClass: "usage_limit", + error: { + type: "provider.quota", + message: "Upstream request failed: Account budget exceeded", + status: 429, + }, + }, + { + name: "a provider.rate-limit failure (429)", + failureClass: "usage_limit", + error: { + type: "provider.rate-limit", + message: "Rate limit exceeded: free-models-per-min.", + status: 429, + }, + }, + ] as const)("classifies $name as $failureClass", ({ error, failureClass }) => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.failed", { sessionID: SESSION, error }), + ]); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + const ended = yield* Fiber.join(terminal); + assert.equal(ended?.status, "failed"); + assert.deepEqual(ended?.failure, { + class: failureClass, + code: error.type, + message: error.message, + retryable: null, + }); + }).pipe(Effect.scoped), + ); + it.effect("ends the turn when its terminal event is one this build cannot decode", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index d2bd8a7ac0315..bf50bbc8395ee 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -26,6 +26,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import { replayAndBufferProjectedLiveEvents } from "./LiveStreamBudget.ts"; import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts"; import { projectDomainEventForWire } from "./WireProjection.ts"; +import { makeRunOutcomeRecorder, type RunOutcome } from "./RunMetrics.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; @@ -243,8 +244,23 @@ const layerBase: Layer.Layer< ); } }); - const publishStoredEvents = (events: ReadonlyArray) => - eventStore.publishCommitted(events).pipe(Effect.andThen(publishLiveEvents(events))); + const runOutcomes = makeRunOutcomeRecorder((threadId) => + // One payload row; unlike the shell, it still resolves a deleted child thread. + projectionStore + .getThread(threadId) + .pipe(Effect.map((thread) => thread.lineage.relationshipToParent === "subagent")), + ); + // Recorded before publication, so an interrupted publish cannot lose a committed outcome. + const publishStoredEvents = ( + events: ReadonlyArray, + outcomes: ReadonlyArray, + ) => + runOutcomes + .record(outcomes) + .pipe( + Effect.andThen(eventStore.publishCommitted(events)), + Effect.andThen(publishLiveEvents(events)), + ); // Transactions commit one at a time, but each writer publishes after its // commit. If a writer is descheduled in between, a later commit reaches @@ -373,6 +389,8 @@ const layerBase: Layer.Layer< updated_at = excluded.updated_at `; } + // Read lineage here, on the connection this transaction holds; see makeRunOutcomeRecorder. + return yield* runOutcomes.prepare(storedEvents); }); const writeEffect = Effect.fn("orchestrationV2.EventSink.write")(function* ( @@ -384,7 +402,7 @@ const layerBase: Layer.Layer< "orchestration_v2.thread_id": input.events[0]?.threadId ?? null, }); - return yield* commitThenPublish( + const { committed } = yield* commitThenPublish( Effect.gen(function* () { // Check ownership in the same transaction that claims the native session. const imported = input.unlessNativeThreadOwned; @@ -398,7 +416,7 @@ const layerBase: Layer.Layer< AND json_extract(payload_json, '$.nativeThreadRef.nativeId') = ${imported.nativeThreadRef.nativeId} LIMIT 1 `; - if (owners.length > 0) return []; + if (owners.length > 0) return { committed: [], outcomes: [] }; } // Checkpoint capture can finish after a cohort writer reads its parent run. // Merge in this transaction so the event log and projection keep that completion. @@ -433,18 +451,19 @@ const layerBase: Layer.Layer< ...(input.commandId === undefined ? {} : { commandId: input.commandId }), events: normalized, }); - yield* applyStoredEvents(committed); + const outcomes = yield* applyStoredEvents(committed); yield* effectOutbox.enqueue(input.effects); - return committed; + return { committed, outcomes }; }).pipe(Effect.withTracerEnabled(false)), - (storedEvents) => + ({ committed, outcomes }) => Effect.gen(function* () { if (input.effects.length > 0) { yield* effectOutbox.notifyAvailable(input.effects.length); } - yield* publishStoredEvents(storedEvents); + yield* publishStoredEvents(committed, outcomes); }), ); + return committed; }); const writeIfRunCurrentEffect = Effect.fn("orchestrationV2.EventSink.writeIfRunCurrent")( @@ -491,9 +510,9 @@ const layerBase: Layer.Layer< ...(input.commandId === undefined ? {} : { commandId: input.commandId }), events: normalized, }); - yield* applyStoredEvents(storedEvents); + const outcomes = yield* applyStoredEvents(storedEvents); yield* effectOutbox.enqueue(input.effects ?? []); - return { committed: true as const, storedEvents }; + return { committed: true as const, storedEvents, outcomes }; }), (result) => Effect.gen(function* () { @@ -501,7 +520,7 @@ const layerBase: Layer.Layer< if (input.effects !== undefined && input.effects.length > 0) { yield* effectOutbox.notifyAvailable(input.effects.length); } - yield* publishStoredEvents(result.storedEvents); + yield* publishStoredEvents(result.storedEvents, result.outcomes); }), ); }, @@ -556,10 +575,13 @@ const layerBase: Layer.Layer< ...(input.commandId === undefined ? {} : { commandId: input.commandId }), events: normalized, }); - yield* applyStoredEvents(storedEvents); - return { committed: true as const, storedEvents }; + const outcomes = yield* applyStoredEvents(storedEvents); + return { committed: true as const, storedEvents, outcomes }; }), - (result) => (result.committed ? publishStoredEvents(result.storedEvents) : Effect.void), + (result) => + result.committed + ? publishStoredEvents(result.storedEvents, result.outcomes) + : Effect.void, ); }); @@ -589,10 +611,13 @@ const layerBase: Layer.Layer< const normalized = yield* normalizeEvents(input.events); const storedEvents = yield* eventStore.append({ events: normalized }); - yield* applyStoredEvents(storedEvents); - return { committed: true as const, storedEvents }; + const outcomes = yield* applyStoredEvents(storedEvents); + return { committed: true as const, storedEvents, outcomes }; }), - (result) => (result.committed ? publishStoredEvents(result.storedEvents) : Effect.void), + (result) => + result.committed + ? publishStoredEvents(result.storedEvents, result.outcomes) + : Effect.void, ); }); @@ -642,7 +667,7 @@ const layerBase: Layer.Layer< new Error(`Command ${input.commandId} produced no orchestration events.`), ); } - yield* applyStoredEvents(storedEvents); + const outcomes = yield* applyStoredEvents(storedEvents); yield* effectOutbox.enqueue(input.effects); const receipt: CommandReceiptStore.CommandReceiptV2 = { commandId: input.commandId, @@ -661,7 +686,7 @@ const layerBase: Layer.Layer< threadId: input.threadId, ...input.cancelUnsettledEffects, }); - return { receipt, storedEvents, committed: true as const, cancelledEffectIds }; + return { receipt, storedEvents, outcomes, committed: true as const, cancelledEffectIds }; }), (result) => Effect.gen(function* () { @@ -669,7 +694,7 @@ const layerBase: Layer.Layer< if (result.committed && input.effects.length > 0) { yield* effectOutbox.notifyAvailable(input.effects.length); } - if (result.committed) yield* publishStoredEvents(result.storedEvents); + if (result.committed) yield* publishStoredEvents(result.storedEvents, result.outcomes); }), ); return { diff --git a/apps/server/src/orchestration-v2/IdleCompactionService.ts b/apps/server/src/orchestration-v2/IdleCompactionService.ts index 337188753f717..30c81ba703323 100644 --- a/apps/server/src/orchestration-v2/IdleCompactionService.ts +++ b/apps/server/src/orchestration-v2/IdleCompactionService.ts @@ -1,6 +1,7 @@ import { CommandId, MessageId, + resolveIdleCompaction, resolveProjectProviderInstanceEnabled, type RunId, type ThreadId, @@ -18,8 +19,9 @@ import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; /** - * Beta: when a Claude run completes, waits until shortly before its prompt - * cache expires and sends `/compact` as a visible T3 Code turn. Timers live in + * Beta: when a run completes on a Claude instance set to compact before the + * cache expires, waits until shortly before its prompt cache expires and sends + * `/compact` as a visible T3 Code turn. Timers live in * memory, so a server restart skips compactions that were still waiting. */ export const make = Effect.gen(function* () { @@ -32,7 +34,6 @@ export const make = Effect.gen(function* () { // Loops once after the wait: the thread may have moved on. while (true) { const current = yield* settings.getSettings; - if (!current.compactIdleThreads) return; const records = yield* projections.getThreadRecords( threadId, [ @@ -47,6 +48,8 @@ export const make = Effect.gen(function* () { { messageRunIds: [runId], turnItemStatuses: ["pending", "running", "waiting"] }, ); if ( + resolveIdleCompaction(current, records.thread.providerInstanceId) !== + "beforeCacheExpires" || !resolveProjectProviderInstanceEnabled( current, records.thread.projectId, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5d8eeda45d1a7..78b37313b8764 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -527,6 +527,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "subagent.stop": case "run.interrupt": case "queued-message.promote-to-steer": + case "queued-run.start": case "queue.resume": case "queued-run.reorder": case "queued-run.cancel": @@ -1322,13 +1323,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const failQueuedRunStart = Effect.fn("Orchestrator.failQueuedRunStart")(function* ( threadId: ThreadId, cause: unknown, + queuedRunId?: RunId, ) { const projection = yield* projectionStore.getThreadRecords( threadId, ["runs", "nodes", "attempts", "providerThreads", "turnItems", "messages"], { turnItemTypes: [], messageRoles: ["user"] }, ); - const queuedRun = nextQueuedRun(projection); + const queuedRun = + queuedRunId === undefined + ? nextQueuedRun(projection) + : projection.runs.find((run) => run.id === queuedRunId && run.status === "queued"); if (queuedRun === undefined) return; const now = yield* DateTime.now; const rootNode = projection.nodes.find((node) => node.id === queuedRun.rootNodeId); @@ -1429,7 +1434,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const startNextQueuedRun = Effect.fn("Orchestrator.startNextQueuedRun")( function* ( threadId: ThreadId, - options?: { readonly failedRunId?: RunId; readonly endedRunId?: RunId }, + options?: { + readonly failedRunId?: RunId; + readonly endedRunId?: RunId; + readonly queuedRunId?: RunId; + }, ) { if (!(yield* projectionStore.canStartQueuedRun(threadId))) return; const projection = yield* readCommandProjection(threadId); @@ -1443,7 +1452,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; } - const storedQueuedRun = nextQueuedRun(projection); + const storedQueuedRun = + options?.queuedRunId === undefined + ? nextQueuedRun(projection) + : projection.runs.find( + (run) => run.id === options.queuedRunId && run.status === "queued", + ); if (storedQueuedRun === undefined) { return; } @@ -2161,8 +2175,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ], ); }, - (effect, threadId, _options?) => - effect.pipe(Effect.catch((cause) => failQueuedRunStart(threadId, cause))), + (effect, threadId, options?) => + effect.pipe( + Effect.catch((cause) => failQueuedRunStart(threadId, cause, options?.queuedRunId)), + ), ); const resumeQueuedRuns = Effect.gen(function* () { @@ -11861,6 +11877,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "queued-message.promote-to-steer": yield* dispatchQueuedMessagePromoteToSteer(command, events, effects); break; + case "queued-run.start": case "queue.resume": { const projection = yield* loadProjectionForCommand( command, @@ -11874,7 +11891,30 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is not active.`, }); } - const nextRun = nextQueuedRun(projection); + const selectedRunId = command.type === "queued-run.start" ? command.runId : undefined; + const selectedRun = + selectedRunId === undefined + ? undefined + : projection.runs.find((run) => run.id === selectedRunId && run.status === "queued"); + if (selectedRunId !== undefined) { + const selectedMessage = projection.messages.find( + (message) => message.id === selectedRun?.userMessageId, + ); + if ( + selectedRun === undefined || + selectedMessage === undefined || + selectedMessage.delegatedCompletion !== undefined || + selectedMessage.notification !== undefined || + projection.runs.some(isBlockingRun) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "A queued user message can only be sent now while the thread is stopped.", + }); + } + } + const nextRun = selectedRun ?? nextQueuedRun(projection); if ( usageLimitHoldsQueue( projection, @@ -12042,8 +12082,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - if (command.type === "queue.resume") { - yield* mapDispatchError(command)(startNextQueuedRun(command.threadId)); + if (command.type === "queue.resume" || command.type === "queued-run.start") { + yield* mapDispatchError(command)( + startNextQueuedRun( + command.threadId, + command.type === "queued-run.start" ? { queuedRunId: command.runId } : undefined, + ), + ); } return { sequence: receipt.resultSequence, @@ -12286,8 +12331,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio detail: committed.receipt.error ?? "Previously rejected.", }); } - if (command.type === "queue.resume") { - yield* mapDispatchError(command)(startNextQueuedRun(command.threadId)); + if (command.type === "queue.resume" || command.type === "queued-run.start") { + yield* mapDispatchError(command)( + startNextQueuedRun( + command.threadId, + command.type === "queued-run.start" ? { queuedRunId: command.runId } : undefined, + ), + ); } if (command.type === "notification.delivery.accept") { yield* mapDispatchError(command)(offerDelegatedCompletionDeliveries(command.threadId)); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 0e7ecfa9f0147..8fca16a101725 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -7,6 +7,7 @@ import { NodeId, ProviderSessionId, ProviderThreadId, + ProviderTurnId, ProviderDriverKind, ProviderInstanceId, ProviderSetupError, @@ -271,6 +272,14 @@ function makeLocalCommandHarness(input: { readonly interruptRunBeforeOpenFailure?: boolean; readonly writeFailure?: unknown; readonly failReadsAfterRunning?: boolean; + readonly previousTurn?: { + readonly runStatus: "failed" | "completed"; + readonly turnStatus: "running" | "completed" | "interrupted"; + readonly differentThread?: boolean; + readonly liveSession?: boolean; + readonly interruptFailure?: boolean; + readonly interruptCurrentRun?: boolean; + }; }) { const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); @@ -456,6 +465,74 @@ function makeLocalCommandHarness(input: { ), }; } + const previousTurnId = ProviderTurnId.make("previous-provider-turn"); + if (input.previousTurn !== undefined) { + const previousRunId = RunId.make("previous-failed-run"); + const previousAttemptId = RunAttemptId.make("previous-failed-attempt"); + const previousProviderThreadId = input.previousTurn.differentThread + ? oldProviderThreadId + : providerThreadId; + projection = { + ...projection, + runs: [ + { + ...run, + id: previousRunId, + ordinal: 1, + status: input.previousTurn.runStatus, + rootNodeId: NodeId.make("previous-root"), + activeAttemptId: previousAttemptId, + providerThreadId: previousProviderThreadId, + completedAt: now, + }, + run, + ], + providerThreads: [ + { + ...providerThread, + nativeThreadRef: { + driver: providerThread.driver, + nativeId: "existing-native-conversation", + strength: "strong", + }, + }, + ], + providerTurns: [ + { + id: previousTurnId, + providerThreadId: previousProviderThreadId, + nodeId: NodeId.make("previous-root"), + runAttemptId: previousAttemptId, + ordinal: 1, + nativeTurnRef: null, + status: input.previousTurn.turnStatus, + startedAt: now, + completedAt: input.previousTurn.turnStatus === "running" ? null : now, + }, + ], + }; + } + let providerBusy = + input.previousTurn?.runStatus === "failed" && + input.previousTurn.turnStatus === "running" && + !input.previousTurn.differentThread && + input.previousTurn.liveSession !== false; + const interruptTurn = vi.fn((request: ProviderAdapter.ProviderAdapterV2InterruptInput) => + Effect.gen(function* () { + expect(request.providerTurnId).toBe(previousTurnId); + expect(request.providerThread.id).toBe(providerThreadId); + if (input.previousTurn?.interruptFailure) { + return yield* new ProviderAdapter.ProviderAdapterInterruptError({ + driver: providerThread.driver, + providerThreadId, + providerTurnId: request.providerTurnId, + cause: "provider interrupt failed", + }); + } + providerBusy = false; + if (input.previousTurn?.interruptCurrentRun) interruptRun(); + }), + ); const events: Array = []; const interruptRun = () => { projection = { @@ -515,7 +592,7 @@ function makeLocalCommandHarness(input: { ), ), ) - : input.failReadsAfterRunning === true + : input.failReadsAfterRunning === true || input.previousTurn !== undefined ? Effect.succeed({ driver: providerThread.driver, providerSession: { @@ -531,14 +608,17 @@ function makeLocalCommandHarness(input: { lastError: null, }, ensureThread: () => Effect.succeed(providerThread), + resumeThread: () => Effect.succeed(projection.providerThreads[0]), } as never) : Effect.die("A local command must not open a native session."), ); const startRootRun = vi.fn< (input: RunExecutionService.RunExecutionServiceV2StartRootRunInput) => Effect.Effect >(() => - input.failReadsAfterRunning === true - ? Effect.void + input.failReadsAfterRunning === true || input.previousTurn !== undefined + ? Effect.suspend(() => + providerBusy ? Effect.die("The previous provider turn is still running.") : Effect.void, + ) : Effect.die("A local command must not start a native turn."), ); const failReadIfRunning = Effect.suspend(() => @@ -642,7 +722,19 @@ function makeLocalCommandHarness(input: { }), ), }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + open, + get: () => + Effect.succeed( + input.previousTurn?.liveSession === false + ? Option.none() + : Option.some({ + driver: providerThread.driver, + providerSessionId, + interruptTurn, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime), + ), + }), Layer.mock(ProviderAuthService.ProviderAuthService)({ tryHandlePromptCommand }), Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }), Layer.mock(RuntimePolicy.RuntimePolicyV2)({ @@ -662,6 +754,7 @@ function makeLocalCommandHarness(input: { open, writeIfRunCurrent, startRootRun, + interruptTurn, tryHandlePromptCommand, events, oldInstanceId, @@ -681,6 +774,106 @@ function makeLocalCommandHarness(input: { }; } +effectIt.effect("recovers an abandoned failed turn before continuing its native conversation", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + previousTurn: { runStatus: "failed", turnStatus: "running" }, + }); + + yield* harness.start; + + expect(harness.interruptTurn).toHaveBeenCalledOnce(); + expect(harness.projection().runs.at(-1)?.status).toBe("running"); + expect(harness.projection().providerTurns[0]).toMatchObject({ + status: "interrupted", + completedAt: expect.anything(), + }); + expect(harness.startRootRun).toHaveBeenCalledOnce(); + expect(harness.startRootRun.mock.calls[0]?.[0].providerThread.nativeThreadRef?.nativeId).toBe( + "existing-native-conversation", + ); + }), +); + +effectIt.effect.each([ + { runStatus: "failed", turnStatus: "completed" }, + { runStatus: "completed", turnStatus: "completed" }, + { runStatus: "failed", turnStatus: "running", differentThread: true }, + { runStatus: "failed", turnStatus: "running", liveSession: false }, + { runStatus: "failed", turnStatus: "interrupted", interruptFailure: true }, +] as const)("continues without interrupting when recovery does not apply: %j", (previousTurn) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ text: "Continue", previousTurn }); + + yield* harness.start; + + expect(harness.interruptTurn).not.toHaveBeenCalled(); + expect(harness.projection().runs.at(-1)?.status).toBe("running"); + }), +); + +effectIt.effect("does not start another turn when abandoned-turn cleanup fails", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + previousTurn: { runStatus: "failed", turnStatus: "running", interruptFailure: true }, + }); + + yield* harness.start; + + expect(harness.interruptTurn).toHaveBeenCalledOnce(); + expect(harness.startRootRun).not.toHaveBeenCalled(); + expect(harness.projection().runs.at(-1)?.status).toBe("failed"); + }), +); + +effectIt.effect("retries abandoned-turn cleanup without failing the continuation early", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + previousTurn: { runStatus: "failed", turnStatus: "running", interruptFailure: true }, + }); + + const error = yield* harness.startWithRetry.pipe(Effect.flip); + + expect(error._tag).toBe("ProviderTurnStartError"); + expect(harness.projection().runs.at(-1)?.status).toBe("starting"); + expect(harness.open).not.toHaveBeenCalled(); + }), +); + +effectIt.effect("does not start the continuation if recording recovery fails", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + previousTurn: { runStatus: "failed", turnStatus: "running" }, + writeFailure: new Error("database unavailable"), + }); + + const error = yield* harness.start.pipe(Effect.flip); + + expect(error._tag).toBe("ProviderTurnStartError"); + expect(harness.projection().runs.at(-1)?.status).toBe("starting"); + expect(harness.projection().providerTurns[0]?.status).toBe("running"); + expect(harness.open).not.toHaveBeenCalled(); + }), +); + +effectIt.effect("does not resume a continuation stopped while recovery was in flight", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + previousTurn: { runStatus: "failed", turnStatus: "running", interruptCurrentRun: true }, + }); + + yield* harness.start; + + expect(harness.projection().runs.at(-1)?.status).toBe("interrupted"); + expect(harness.open).not.toHaveBeenCalled(); + }), +); + effectIt.effect.each(["cleanup-first", "startup-first"])( "coordinates provider startup with %s", (order) => diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index bcbfecfa75482..f8a109d4257e8 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -600,29 +600,6 @@ export const layer: Layer.Layer< const existingSessionProjection = projection.providerSessions.find( (candidate) => candidate.id === providerSessionId, ); - // T3-CUSTOM(expbkt3): policy and workspace preparation can be slow. - if (!(yield* backgroundAllowed)) return; - const sessionResult = yield* Effect.result( - providerSessions.open({ - threadId: projection.thread.id, - messageId: message.id, // T3-CUSTOM(expbkt3): queued turns retain their actual sender. - providerSessionId, - modelSelection: run.modelSelection, - runtimePolicy: resolvedRuntimePolicy, - ...(existingSessionProjection === undefined - ? {} - : { resumeFromSession: existingSessionProjection }), - ...(providerThread.nativeThreadRef?.nativeId == null - ? {} - : { initialNativeThreadId: providerThread.nativeThreadRef.nativeId }), - ...(providerThread.nativeMetadata?.itemIdentityVersion === undefined - ? {} - : { - initialProviderItemIdentityVersion: - providerThread.nativeMetadata.itemIdentityVersion, - }), - }), - ); const settleStartFailure = Effect.fn("ProviderTurnStartService.settleStartFailure")( function* (failed: { readonly signal: string; @@ -654,6 +631,94 @@ export const layer: Layer.Layer< }); }, ); + const failedRuns = new Map( + projection.runs + .filter( + (previous) => + previous.ordinal < run.ordinal && + previous.providerThreadId === providerThread.id && + previous.status === "failed", + ) + .map((previous) => [previous.activeAttemptId, previous] as const), + ); + const abandonedTurns = projection.providerTurns.flatMap((turn) => { + const previous = failedRuns.get(turn.runAttemptId); + return turn.providerThreadId === providerThread.id && + turn.status === "running" && + previous !== undefined && + turn.nodeId === previous.rootNodeId + ? [{ turn, run: previous }] + : []; + }); + const abandonedTurn = abandonedTurns.at(-1)?.turn; + if (abandonedTurn !== undefined) { + const liveSession = yield* providerSessions.get(providerSessionId); + if (Option.isSome(liveSession)) { + // Older versions could fail the run without stopping its provider. + // Recover that exact turn before reusing its native conversation. + const recovered = yield* Effect.result( + liveSession.value.interruptTurn({ + providerThread, + providerTurnId: abandonedTurn.id, + requestRuntimeRestart: true, + }), + ); + if (recovered._tag === "Failure") { + if (input.willRetry === true) return yield* recovered.failure; + yield* settleStartFailure({ + signal: "provider-turn-recovery-failure", + title: "Provider turn recovery failed", + error: recovered.failure, + }); + return; + } + } + const completedAt = yield* DateTime.now; + const repaired = yield* eventSink.writeIfRunCurrent({ + threadId: projection.thread.id, + runId: run.id, + activeAttemptId: attempt.id, + expectedStatus: "starting", + events: yield* Effect.forEach(abandonedTurns, ({ turn, run: failedRun }) => + Effect.gen(function* () { + return { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "provider-turn.updated" as const, + threadId: projection.thread.id, + runId: failedRun.id, + nodeId: turn.nodeId, + providerInstanceId: failedRun.providerInstanceId, + occurredAt: completedAt, + payload: { ...turn, status: "interrupted" as const, completedAt }, + }; + }), + ), + }); + if (!repaired.committed) return; + } + // T3-CUSTOM(expbkt3): policy and workspace preparation can be slow. + if (!(yield* backgroundAllowed)) return; + const sessionResult = yield* Effect.result( + providerSessions.open({ + threadId: projection.thread.id, + messageId: message.id, // T3-CUSTOM(expbkt3): queued turns retain their actual sender. + providerSessionId, + modelSelection: run.modelSelection, + runtimePolicy: resolvedRuntimePolicy, + ...(existingSessionProjection === undefined + ? {} + : { resumeFromSession: existingSessionProjection }), + ...(providerThread.nativeThreadRef?.nativeId == null + ? {} + : { initialNativeThreadId: providerThread.nativeThreadRef.nativeId }), + ...(providerThread.nativeMetadata?.itemIdentityVersion === undefined + ? {} + : { + initialProviderItemIdentityVersion: + providerThread.nativeMetadata.itemIdentityVersion, + }), + }), + ); if (sessionResult._tag === "Failure") { if (input.willRetry === true) return yield* sessionResult.failure; yield* settleStartFailure({ diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index aaf4104ba81f8..fc2491220b5df 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -920,6 +920,43 @@ describe("PullRequestSyncReactor", () => { ), ); + it.effect.each([true, false])( + "syncs linked draft status %s as soon as a reader reports a change", + (isDraft) => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const draft = yield* Ref.make(!isDraft); + const invalidated = yield* Ref.make>([]); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { pullRequests: [makeLink(7, { isDraft: !isDraft })] }), + ]), + summary: (input) => + Ref.get(draft).pipe(Effect.map((isDraft) => makeSummary(input, { isDraft }))), + invalidate: ({ reference }) => + Ref.update(invalidated, (numbers) => [...numbers, reference?.number ?? -1]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Ref.set(draft, isDraft); + yield* Queue.offer(fixture.stateChanges, { + host: "github.com", + repository: "owner/repository", + number: 7, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual(yield* Ref.get(invalidated), [7]); + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => command.snapshot.isDraft), + [isDraft], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ); + it.effect("preserves a refresh requested while an older host read is in flight", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 4dcfaa069f5d7..e7937f8a1fc9f 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -2487,6 +2487,152 @@ it.effect("refreshes pull requests after a provider stream exits with an error", }), ); +it.effect.each(["failure", "defect", "interrupt-failure", "ownership-lost"] as const)( + "stops the live provider and background work before settling an ingestion %s", + (failureMode) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const stopped = yield* Deferred.make(); + const backgroundWorkRunning = yield* Ref.make(true); + const backgroundWorkAtFinalization = yield* Ref.make(null); + const now = yield* DateTime.now; + const interrupts = yield* Ref.make< + ReadonlyArray + >([]); + const ids = backgroundScenarioIds(`ingestion:${failureMode}`); + const { observed, finalEvents } = yield* captureRootRunTermination({ + key: `ingestion:${failureMode}`, + initialProviderTurnId: null, + rejectTerminalWrite: failureMode === "ownership-lost", + shouldFinalizeRun: () => Effect.succeed(true), + startTurn: () => + Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(stopped))), + events: () => + Stream.unwrap( + Deferred.await(started).pipe( + Effect.as( + Stream.make({ + type: "provider_turn.updated" as const, + driver, + providerTurn: { + id: ids.rootProviderTurnId, + providerThreadId: ids.providerThreadId, + nodeId: ids.rootNodeId, + runAttemptId: ids.attemptId, + nativeTurnRef: null, + ordinal: 1, + status: "running" as const, + startedAt: now, + completedAt: null, + }, + }), + ), + ), + ), + ingestNormalized: () => { + const error = new ProviderEventIngestor.ProviderEventPublishError({ + providerSessionId: ProviderSessionId.make(`session:${failureMode}`), + eventCount: 1, + cause: new Error("SQLITE_FULL: database or disk is full"), + }); + return failureMode === "defect" ? Effect.die(error) : Effect.fail(error); + }, + interruptTurn: (input) => + Ref.update(interrupts, (current) => [...current, input]).pipe( + Effect.andThen( + input.requestRuntimeRestart === true && failureMode !== "interrupt-failure" + ? Ref.set(backgroundWorkRunning, false) + : Effect.void, + ), + Effect.andThen(Deferred.succeed(stopped, undefined)), + Effect.andThen( + failureMode === "interrupt-failure" + ? Effect.fail( + new ProviderAdapter.ProviderAdapterInterruptError({ + driver, + providerThreadId: input.providerThread.id, + providerTurnId: input.providerTurnId, + cause: "provider already exited", + }), + ) + : Effect.void, + ), + ), + beforeTerminalWrite: () => + Effect.gen(function* () { + const calls = yield* Ref.get(interrupts); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.providerTurnId, ids.rootProviderTurnId); + assert.equal(calls[0]?.providerThread.id, ids.providerThreadId); + yield* Ref.set(backgroundWorkAtFinalization, yield* Ref.get(backgroundWorkRunning)); + return true; + }), + }); + if (failureMode === "ownership-lost") { + assert.deepEqual(finalEvents, []); + assert.deepEqual(observed, []); + } else { + assert.equal( + yield* Ref.get(backgroundWorkAtFinalization), + failureMode === "interrupt-failure", + ); + assert.deepEqual(observed, ["run:failed", "pull-requests-refreshed"]); + const repaired = finalEvents.find((event) => event.type === "provider-turn.updated"); + if (failureMode === "interrupt-failure") { + assert.isUndefined(repaired); + } else { + assert.equal(repaired?.payload.id, ids.rootProviderTurnId); + assert.equal(repaired?.payload.status, "interrupted"); + assert.isNotNull(repaired?.payload.completedAt); + } + } + }), +); + +it.effect("does not interrupt an ended provider when its terminal write fails", () => + Effect.gen(function* () { + const interrupts = yield* Ref.make(0); + const writes = yield* Ref.make(0); + const { observed } = yield* captureRootRunTermination({ + key: "terminal-write-failure", + shouldFinalizeRun: () => Effect.succeed(true), + events: (ids) => Stream.make(rootTerminalEvent(ids, "completed")), + interruptTurn: () => Ref.update(interrupts, (count) => count + 1), + beforeTerminalWrite: () => + Effect.gen(function* () { + if ((yield* Ref.updateAndGet(writes, (count) => count + 1)) === 1) { + return yield* new EventSink.EventSinkWriteError({ eventCount: 1 }); + } + return true; + }), + }); + assert.equal(yield* Ref.get(interrupts), 0); + assert.deepEqual(observed, ["run:failed", "pull-requests-refreshed"]); + }), +); + +it.effect("does not interrupt a provider when ingestion fails before the root turn is known", () => + Effect.gen(function* () { + const interrupts = yield* Ref.make(0); + const { observed } = yield* captureRootRunTermination({ + key: "unknown-turn-stream-error", + initialProviderTurnId: null, + shouldFinalizeRun: () => Effect.succeed(true), + events: () => + Stream.fail( + new ProviderAdapter.ProviderAdapterEventStreamError({ + driver, + providerSessionId: ProviderSessionId.make("session:unknown-turn"), + cause: "provider process exited", + }), + ), + interruptTurn: () => Ref.update(interrupts, (count) => count + 1), + }); + assert.equal(yield* Ref.get(interrupts), 0); + assert.deepEqual(observed, ["run:failed", "pull-requests-refreshed"]); + }), +); + it.effect("fails the run when its event stream ends without a root terminal", () => Effect.gen(function* () { const { observed, finalEvents } = yield* captureRootRunTermination({ @@ -2564,6 +2710,8 @@ function captureRootRunTermination(input: { ProviderAdapter.ProviderAdapterV2Error >; readonly startTurn?: ProviderAdapter.ProviderAdapterV2SessionRuntime["startTurn"]; + readonly interruptTurn?: ProviderAdapter.ProviderAdapterV2SessionRuntime["interruptTurn"]; + readonly initialProviderTurnId?: ProviderTurnId | null; readonly onStartFailure?: ( error: RunExecutionService.RunExecutionServiceV2Error, ) => Effect.Effect; @@ -2704,6 +2852,7 @@ function captureRootRunTermination(input: { ), }), startTurn: input.startTurn ?? (() => Effect.void), + interruptTurn: input.interruptTurn ?? (() => Effect.void), } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: ids.runId, @@ -2724,7 +2873,10 @@ function captureRootRunTermination(input: { } as OrchestrationV2ProviderThread, attempt: { id: ids.attemptId, - providerTurnId: ids.rootProviderTurnId, + providerTurnId: + input.initialProviderTurnId === undefined + ? ids.rootProviderTurnId + : input.initialProviderTurnId, } as OrchestrationV2RunAttempt, attemptId: ids.attemptId, providerTurnOrdinal: 1, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 51007b4f0a08b..a36de95d0f424 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -631,6 +631,7 @@ export const layer: Layer.Layer< readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly openRunOwnedSubagents?: OpenRunOwnedSubagentProjection; + readonly interruptedProviderTurn?: OrchestrationV2ProviderTurn; readonly terminal: ProviderTerminalEvent; readonly completedAt?: DateTime.Utc; readonly failureItemPersisted: boolean; @@ -752,6 +753,24 @@ export const layer: Layer.Layer< : [], events: [ ...cascadedSubagentEvents, + ...(input.interruptedProviderTurn === undefined + ? [] + : [ + { + id: yield* allocateEventId(), + type: "provider-turn.updated" as const, + threadId: input.run.threadId, + runId: input.run.id, + nodeId: input.rootNode.id, + providerInstanceId: input.run.providerInstanceId, + occurredAt: completedAt, + payload: { + ...input.interruptedProviderTurn, + status: "interrupted" as const, + completedAt, + }, + }, + ]), ...(finalizedAttempt === null ? [] : [ @@ -975,6 +994,7 @@ export const layer: Layer.Layer< const terminalEvent = yield* Ref.make(null); const latestTurnItemOrdinal = yield* Ref.make(input.providerTurnOrdinal * 100); const latestProviderThread = yield* Ref.make(input.providerThread); + const latestRootProviderTurn = yield* Ref.make(null); const routeIdentity: ProviderEventRouteIdentity = { threadId: input.run.threadId, runId: input.run.id, @@ -1331,6 +1351,13 @@ export const layer: Layer.Layer< Stream.tap(({ event, accepted }) => Effect.gen(function* () { if (!accepted) return; + if ( + event.type === "provider_turn.updated" && + event.providerTurn.runAttemptId === input.attempt.id && + event.providerTurn.providerThreadId === input.providerThread.id + ) { + yield* Ref.set(latestRootProviderTurn, event.providerTurn); + } let storedEventCount = 0; const deliveredEvent = filterAssistantEvent( event, @@ -1449,6 +1476,28 @@ export const layer: Layer.Layer< }); if (yield* Ref.get(rootRunFinalized)) return; const providerThread = yield* Ref.get(latestProviderThread); + const routing = yield* Ref.get(eventRouting); + // Stop background work too; the failed consumer cannot persist terminal events. + const interruptedProviderTurn = + routing.rootProviderTurnId !== null && !routing.rootTurnEnded + ? yield* input.session + .interruptTurn({ + providerThread, + providerTurnId: routing.rootProviderTurnId, + requestRuntimeRestart: true, + }) + .pipe( + Effect.as(yield* Ref.get(latestRootProviderTurn)), + Effect.catchCauseIf( + (interruptCause) => !Cause.hasInterruptsOnly(interruptCause), + (interruptCause) => + Effect.logWarning( + "orchestration V2 failed to interrupt turn after ingestion failure", + { runId: input.run.id, cause: interruptCause }, + ).pipe(Effect.as(null)), + ), + ) + : null; const latestItemOrdinal = yield* Ref.get(latestTurnItemOrdinal); const openSubagents = yield* Ref.get(openRunOwnedSubagents); let nextRetryWarningAt = completedAt; @@ -1463,6 +1512,7 @@ export const layer: Layer.Layer< expectedStatus: "running", }, openRunOwnedSubagents: openSubagents, + ...(interruptedProviderTurn === null ? {} : { interruptedProviderTurn }), completedAt, terminal: makeFailedTerminalEvent( makeProviderFailure({ cause: Cause.squash(cause), class: "unknown" }), diff --git a/apps/server/src/orchestration-v2/RunMetrics.test.ts b/apps/server/src/orchestration-v2/RunMetrics.test.ts new file mode 100644 index 0000000000000..fead1c29a969e --- /dev/null +++ b/apps/server/src/orchestration-v2/RunMetrics.test.ts @@ -0,0 +1,284 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, + type OrchestrationV2Run, + type OrchestrationV2StoredEvent, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as EventSink from "./EventSink.ts"; +import * as EventStore from "./EventStore.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import { makeRunOutcomeRecorder } from "./RunMetrics.ts"; + +const at = (iso: string) => DateTime.makeUnsafe(iso); +const threadId = ThreadId.make("thread:metrics"); + +// Each test labels its runs with its own provider, since the metric registry is shared. +const runUpdated = ( + sequence: number, + provider: string, + run: Pick & + Partial, +): OrchestrationV2StoredEvent => ({ + sequence, + commandId: null, + event: { + type: "run.updated", + id: EventId.make(`event:${provider}:${sequence}`), + threadId, + occurredAt: at("2026-10-19T12:00:00.000Z"), + payload: { + threadId, + ordinal: 1, + providerInstanceId: ProviderInstanceId.make(provider), + modelSelection: { instanceId: ProviderInstanceId.make(provider), model: "claude-opus-5-5" }, + requestedAt: at("2026-10-19T12:00:00.000Z"), + ...run, + } as OrchestrationV2Run, + }, +}); + +// Prepare inside the transaction, record after its commit, as the EventSink does. +const commitWith = + (recorder: ReturnType>) => + (events: ReadonlyArray) => + recorder.prepare(events).pipe(Effect.flatMap(recorder.record)); + +const runSnapshots = (provider: string) => + Metric.snapshot.pipe( + Effect.map((snapshots) => + snapshots.filter( + (snapshot) => + snapshot.id.startsWith("t3_run") && snapshot.attributes?.["provider"] === provider, + ), + ), + ); + +describe("makeRunOutcomeRecorder", () => { + it.effect("records a finished run once, labelled, with its queue wait and duration", () => + Effect.gen(function* () { + // A subagent thread's first run carries no delegatedTaskId; only its lineage says delegated. + const recorder = makeRunOutcomeRecorder(() => Effect.succeed(true)); + const record = commitWith(recorder); + const finished = { + id: RunId.make("run:metrics:once"), + status: "completed" as const, + startedAt: at("2026-10-19T12:00:30.000Z"), + completedAt: at("2026-10-19T12:10:30.000Z"), + }; + + yield* record([runUpdated(1, "p1", { ...finished, status: "running", completedAt: null })]); + // A transaction that rolls back prepares and never records; it must not reserve the run. + yield* recorder.prepare([runUpdated(2, "p1", finished)]); + yield* record([runUpdated(2, "p1", finished)]); + // Later updates to a finished run, such as its checkpoint, are not another run. + yield* record([runUpdated(3, "p1", finished)]); + + const snapshots = yield* runSnapshots("p1"); + const byId = (id: string) => snapshots.find((snapshot) => snapshot.id === id); + const counter = byId("t3_runs_total"); + assert.strictEqual(counter?.type === "Counter" ? counter.state.count : undefined, 1); + assert.deepStrictEqual(counter?.attributes, { + provider: "p1", + modelFamily: "claude", + outcome: "completed", + delegated: "true", + }); + const histogramSum = (id: string) => { + const snapshot = byId(id); + return snapshot?.type === "Histogram" ? snapshot.state.sum : undefined; + }; + assert.strictEqual(histogramSum("t3_run_queue_wait"), 30_000); + assert.strictEqual(histogramSum("t3_run_duration"), 600_000); + }), + ); + + it.effect("does not count a rollback of a run that already finished", () => + Effect.gen(function* () { + // A fresh recorder stands in for a restart, which forgets the runs already counted. + const record = commitWith(makeRunOutcomeRecorder(() => Effect.succeed(false))); + yield* record([ + runUpdated(1, "p2", { + id: RunId.make("run:metrics:rolled-back"), + status: "rolled_back", + startedAt: at("2026-10-19T12:00:30.000Z"), + completedAt: at("2026-10-20T09:00:00.000Z"), + }), + ]); + + assert.deepStrictEqual(yield* runSnapshots("p2"), []); + }), + ); +}); + +describe("EventSink run outcomes", () => { + const parentId = ThreadId.make("thread:metrics:sink:parent"); + const childId = ThreadId.make("thread:metrics:sink:child"); + const provider = ProviderInstanceId.make("p-sink"); + const now = at("2026-10-19T12:00:00.000Z"); + const thread = (id: ThreadId, lineage: OrchestrationV2AppThread["lineage"]) => + ({ + type: "thread.created", + id: EventId.make(`event:${id}:created`), + threadId: id, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id, + projectId: ProjectId.make("project:metrics"), + title: id, + providerInstanceId: provider, + modelSelection: { instanceId: provider, model: "gpt-6.1" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }) satisfies OrchestrationV2DomainEvent; + const runId = RunId.make("run:metrics:sink:child"); + const run = (status: OrchestrationV2Run["status"]): OrchestrationV2Run => ({ + id: runId, + threadId: childId, + ordinal: 1, + providerInstanceId: provider, + modelSelection: { instanceId: provider, model: "gpt-6.1" }, + providerThreadId: null, + userMessageId: MessageId.make("message:metrics:sink"), + rootNodeId: null, + activeAttemptId: null, + status, + queuePosition: null, + requestedAt: now, + startedAt: at("2026-10-19T12:00:10.000Z"), + completedAt: status === "running" ? null : at("2026-10-19T12:01:10.000Z"), + checkpointId: null, + contextHandoffId: null, + }); + const runEvent = (type: "run.created" | "run.updated", status: OrchestrationV2Run["status"]) => + ({ + type, + id: EventId.make(`event:${runId}:${status}`), + threadId: childId, + runId, + occurredAt: now, + payload: run(status), + }) satisfies OrchestrationV2DomainEvent; + + it.effect("reads lineage inside the transaction, so a concurrent writer cannot deadlock it", () => + Effect.gen(function* () { + const lookupEntered = yield* Deferred.make(); + const releaseLookup = yield* Deferred.make(); + const writerStarted = yield* Deferred.make(); + const pausedLookup = Layer.effect( + ProjectionStore.ProjectionStoreV2, + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + let paused = false; + return { + ...store, + getThread: (id) => + Effect.gen(function* () { + if (id === childId && !paused) { + paused = true; + const inTransaction = Option.isSome( + yield* Effect.serviceOption(sql.transactionService), + ); + yield* Deferred.succeed(lookupEntered, inTransaction); + // Outside the transaction this read would wait on the writer it is racing. + if (inTransaction) yield* Deferred.await(releaseLookup); + } + return yield* store.getThread(id); + }), + }; + }), + ); + const layer = EventSink.layer.pipe( + Layer.provideMerge(pausedLookup), + Layer.provideMerge( + Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe( + Layer.provideMerge(SqlitePersistence.layerMemory), + ), + ), + ); + + yield* Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + yield* sink.write({ + events: [ + thread(parentId, { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: parentId, + }), + thread(childId, { + parentThreadId: parentId, + relationshipToParent: "subagent", + rootThreadId: parentId, + }), + runEvent("run.created", "running"), + ], + }); + const terminal = yield* sink + .write({ events: [runEvent("run.updated", "completed")] }) + .pipe(Effect.forkScoped); + assert.isTrue(yield* Deferred.await(lookupEntered), "lineage read outside the transaction"); + const unrelated = yield* Deferred.succeed(writerStarted, undefined).pipe( + Effect.andThen( + sink.write({ + events: [ + thread(ThreadId.make("thread:metrics:sink:other"), { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: ThreadId.make("thread:metrics:sink:other"), + }), + ], + }), + ), + Effect.forkScoped, + ); + yield* Deferred.await(writerStarted); + yield* Deferred.succeed(releaseLookup, undefined); + + const [terminalEvents, unrelatedEvents] = [ + yield* Fiber.join(terminal), + yield* Fiber.join(unrelated), + ]; + assert.strictEqual(terminalEvents.length, 1); + assert.strictEqual(unrelatedEvents.length, 1); + const counter = (yield* runSnapshots("p-sink")).find( + (snapshot) => snapshot.id === "t3_runs_total", + ); + assert.strictEqual(counter?.type === "Counter" ? counter.state.count : undefined, 1); + assert.strictEqual(counter?.attributes?.["delegated"], "true"); + }).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/RunMetrics.ts b/apps/server/src/orchestration-v2/RunMetrics.ts new file mode 100644 index 0000000000000..17b90d254418a --- /dev/null +++ b/apps/server/src/orchestration-v2/RunMetrics.ts @@ -0,0 +1,113 @@ +import type { + OrchestrationV2Run, + OrchestrationV2StoredEvent, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import type * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Metric from "effect/Metric"; + +import { normalizeModelMetricLabel } from "../observability/Attributes.ts"; +import { + metricAttributes, + runDuration, + runQueueWait, + runsTotal, +} from "../observability/Metrics.ts"; + +const REMEMBERED_RUNS = 4096; +// A rollback rewrites runs that already finished, stamping the rollback time as their completion, +// so it is not counted as an outcome. Declared here because ThreadManagementService imports +// EventSink. +const OUTCOMES: ReadonlySet = new Set([ + "completed", + "failed", + "cancelled", + "interrupted", +]); + +export interface RunOutcome { + readonly runId: RunId; + readonly attributes: ReturnType; + readonly queueWait: Duration.Duration | undefined; + readonly duration: Duration.Duration | undefined; +} + +/** + * Records each run once, when a committed event first shows it finished. A finished run's row is + * updated again later (its checkpoint, a settlement), so recently counted runs are remembered. + * + * A run carries `delegatedTaskId` only when a follow-up is associated with a task, so a subagent + * thread's first run is recognized by its thread's lineage instead. A failed lookup only costs the + * label: the events still commit and publish. + * + * `prepare` reads lineage, so the EventSink runs it inside the write transaction, on the + * connection that transaction already holds. `record` runs after the commit, under the publish + * lane, and must stay free of I/O: a read there waits for the writer connection while holding the + * lane, and the next writer holds that connection while it waits for the lane. + */ +export const makeRunOutcomeRecorder = ( + isSubagentThread: (threadId: ThreadId) => Effect.Effect, +) => { + const counted = new Set(); + // Untraced: both run for every committed batch inside the EventSink operation's span. + // Never reads or writes `counted`, so a transaction that rolls back reserves nothing. + const prepare = Effect.fnUntraced(function* (events: ReadonlyArray) { + const outcomes: Array = []; + // Every finished run is prepared, in order, even one already counted: an earlier outcome in + // this batch may evict it before `record` reaches it, so only `record` decides what counts. + // Only the lineage read is shared. + const delegatedThreads = new Map(); + for (const { event } of events) { + if (event.type !== "run.updated") continue; + const run = event.payload; + if (!OUTCOMES.has(run.status)) continue; + let delegated = run.delegatedTaskId !== undefined || delegatedThreads.get(run.threadId); + if (delegated === undefined) { + delegated = yield* isSubagentThread(run.threadId).pipe(Effect.orElseSucceed(() => false)); + delegatedThreads.set(run.threadId, delegated); + } + outcomes.push({ + runId: run.id, + attributes: metricAttributes({ + provider: run.providerInstanceId, + modelFamily: normalizeModelMetricLabel(run.modelSelection.model), + outcome: run.status, + delegated, + }), + queueWait: + run.startedAt === null ? undefined : DateTime.distance(run.requestedAt, run.startedAt), + duration: + run.startedAt === null || run.completedAt === null + ? undefined + : DateTime.distance(run.startedAt, run.completedAt), + }); + } + return outcomes; + }); + // Uninterruptible so a run is never marked counted with only part of its measurements. + const record = Effect.fnUntraced(function* (outcomes: ReadonlyArray) { + for (const outcome of outcomes) { + // Concurrent writers can prepare the same run; the first to publish counts it. + if (counted.has(outcome.runId)) continue; + counted.add(outcome.runId); + if (counted.size > REMEMBERED_RUNS) counted.delete(counted.values().next().value!); + yield* Metric.update(Metric.withAttributes(runsTotal, outcome.attributes), 1); + if (outcome.queueWait !== undefined) { + yield* Metric.update( + Metric.withAttributes(runQueueWait, outcome.attributes), + outcome.queueWait, + ); + } + if (outcome.duration !== undefined) { + yield* Metric.update( + Metric.withAttributes(runDuration, outcome.attributes), + outcome.duration, + ); + } + } + }, Effect.uninterruptible); + return { prepare, record }; +}; diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 441cf0a6e9027..208934624d9e1 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -550,6 +550,139 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* return { events, started, steered, layer, startFirstTurn }; }); +it.effect("sends a selected held message once and preserves the remaining queue order", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = + yield* nextTurnSelectionHarness("send-held-message"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* Effect.forEach(["queued-first", "selected", "queued-last"], (text) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(text), + threadId, + messageId: MessageId.make(`message:${text}`), + text, + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }), + ); + const before = yield* orchestrator.getThreadProjection(threadId); + const selected = before.runs.find( + (run) => run.userMessageId === MessageId.make("message:selected"), + )!; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + // Restart recovery cancels the active run and holds every queued message. + yield* sink.write({ + events: [ + ...before.runs.map((run) => ({ + id: EventId.make(`restart:${run.id}`), + type: "run.updated" as const, + threadId, + runId: run.id, + occurredAt: now, + payload: + run.status === "queued" + ? { ...run, queueHeld: true } + : { ...run, status: "cancelled" as const, completedAt: now }, + })), + ...before.providerTurns.map((turn) => ({ + id: EventId.make(`restart:${turn.id}`), + type: "provider-turn.updated" as const, + threadId, + occurredAt: now, + payload: { ...turn, status: "cancelled" as const, completedAt: now }, + })), + ], + }); + const command = { + type: "queued-run.start" as const, + commandId: CommandId.make("send-selected"), + threadId, + runId: selected.id, + }; + yield* orchestrator.dispatch(command); + yield* worker.drain(); + yield* orchestrator.dispatch(command); + yield* worker.drain(); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal(after.runs.find((run) => run.id === selected.id)?.status, "running"); + assert.deepEqual( + started.map((input) => input.message.text), + ["first", "selected"], + ); + assert.equal(started[1]?.runId, selected.id); + assert.equal(started[1]?.message.messageId, selected.userMessageId); + assert.deepEqual(started[1]?.modelSelection, runSelection); + assert.deepEqual(steered, []); + assert.lengthOf( + after.messages.filter((message) => message.id === selected.userMessageId), + 1, + ); + assert.deepEqual( + after.runs.filter((run) => run.status === "queued").map((run) => run.userMessageId), + [MessageId.make("message:queued-first"), MessageId.make("message:queued-last")], + ); + assert.isFalse(after.runs.some((run) => run.status === "queued" && run.queueHeld)); + const duplicate = yield* orchestrator + .dispatch({ + ...command, + commandId: CommandId.make("send-selected-again"), + }) + .pipe(Effect.flip); + assert.equal(duplicate._tag, "OrchestratorDispatchError"); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("keeps a message queued when send now races with a running turn", () => + Effect.scoped( + Effect.gen(function* () { + const { started, layer, startFirstTurn } = + yield* nextTurnSelectionHarness("send-queued-race"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("queue"), + threadId, + messageId: MessageId.make("message:queued"), + text: "queued", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const before = yield* orchestrator.getThreadProjection(threadId); + const queued = before.runs.find((run) => run.status === "queued")!; + const error = yield* orchestrator + .dispatch({ + type: "queued-run.start", + commandId: CommandId.make("send-now"), + threadId, + runId: queued.id, + }) + .pipe(Effect.flip); + yield* worker.drain(); + assert.equal(error._tag, "OrchestratorDispatchError"); + assert.equal(started.length, 1); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(after.runs, before.runs); + }).pipe(Effect.provide(layer)); + }), + ), +); + // regression: #19210 a provider that can restart the turn must still steer, since restarting ends its native subagents it.effect( "steers a changed turn-scoped selection into a running turn the provider could restart", diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 8336c705bfdbb..09f1586d32c4d 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -174,6 +174,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray ), ); +effectIt.effect("hydrates a missing JAVA_HOME from the login shell and keeps an explicit one", () => + Effect.acquireUseRelease( + Effect.sync(() => + vi + .spyOn(NodeChildProcess, "execFileSync") + .mockReturnValue( + [ + "__T3CODE_ENV_PATH_START__", + "/opt/homebrew/bin", + "__T3CODE_ENV_PATH_END__", + "__T3CODE_ENV_JAVA_HOME_START__", + "/opt/homebrew/opt/openjdk@17", + "__T3CODE_ENV_JAVA_HOME_END__", + ].join("\n"), + ), + ), + () => + Effect.gen(function* () { + const missing: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin" }; + const explicit: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + JAVA_HOME: "/custom/jdk", + }; + + for (const env of [missing, explicit]) { + yield* fixPath().pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, env), + Effect.provide(NodeServices.layer), + ); + } + + assert.equal(missing.JAVA_HOME, "/opt/homebrew/opt/openjdk@17"); + assert.equal(explicit.JAVA_HOME, "/custom/jdk"); + }), + (probe) => Effect.sync(() => probe.mockRestore()), + ), +); + effectIt.effect("replaces a Path-spelled Windows PATH instead of adding a second key", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { Path: "C:\\Windows" }; diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index b6d0d5a7ad723..dc912bf39c3f6 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -25,7 +25,7 @@ function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): vo // mise's record of the PATH entries it added. It must travel with the // shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper // (`exec mise x claude -- claude`) that then re-runs itself forever. - shellEnvironment = readEnvironmentFromLoginShell(shell, ["PATH", "__MISE_DIFF"]); + shellEnvironment = readEnvironmentFromLoginShell(shell, ["PATH", "__MISE_DIFF", "JAVA_HOME"]); } catch (error) { logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); } @@ -42,6 +42,11 @@ function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): vo if (shellPath && shellEnvironment.__MISE_DIFF) { env.__MISE_DIFF = shellEnvironment.__MISE_DIFF; } + // Android SDK tools are Java programs. A keg-only JDK (Homebrew's openjdk) + // is only reachable through the JAVA_HOME the shell profile sets. + if (!env.JAVA_HOME && shellEnvironment.JAVA_HOME) { + env.JAVA_HOME = shellEnvironment.JAVA_HOME; + } } export function hydratePosixHome( diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 787a729f86b40..73ff1b067e875 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -340,7 +340,7 @@ for (const {} of [ platform: "darwin", installPath: "Applications/WebStorm.app/Contents/MacOS/webstorm", editor: "webstorm", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "darwin", @@ -364,13 +364,13 @@ for (const {} of [ platform: "win32", installPath: "Programs/JetBrains/WebStorm 2026.2/bin/webstorm64.exe", editor: "webstorm", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "win32", installPath: "Programs/WebStorm/bin/webstorm64.exe", editor: "webstorm", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "win32", @@ -382,7 +382,7 @@ for (const {} of [ platform: "linux", installPath: ".local/share/JetBrains/Toolbox/scripts/idea", editor: "idea", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "linux", @@ -472,7 +472,7 @@ it.effect.each([ "--line", "12", "--column", - "4", + "3", "/workspace with spaces/file.ts", ]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 2c24857555bb6..1260f2e3fda80 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -163,9 +163,10 @@ function resolveCommandEditorArgs( onSome: ({ path, line, column }) => [ "--line", line, + // JetBrains IDEs count `--column` from 0; targets count columns from 1. ...Option.match(column, { onNone: () => [], - onSome: (value) => ["--column", value], + onSome: (value) => ["--column", String(Math.max(0, Number(value) - 1))], }), path, ], diff --git a/apps/server/src/project/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts index 4dbbb01217527..6a13af9ef9449 100644 --- a/apps/server/src/project/ProjectCloneTracker.test.ts +++ b/apps/server/src/project/ProjectCloneTracker.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { OrchestrationDispatchCommandError, ProjectId, + projectCloneProgressSummary, SourceControlRepositoryError, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -105,6 +106,45 @@ describe("ProjectCloneTracker", () => { }).pipe(Effect.provide(harness.layer)); }); + it.effect("flags a clone stalled when git goes quiet and clears it when git speaks", () => { + const release = Deferred.makeUnsafe(); + const resumed = Deferred.makeUnsafe(); + const harness = makeHarness({ + clone: (input, options) => + Effect.gen(function* () { + const report = options?.onProgress ?? (() => Effect.void); + yield* report({ stage: "receiving", percent: 24, detail: "168.80 MiB | 20.69 MiB/s" }); + yield* Deferred.await(resumed); + yield* report({ stage: "receiving", percent: 25, detail: "170.00 MiB | 1.00 MiB/s" }); + yield* Deferred.await(release); + return { cwd: input.destinationPath, remoteUrl: input.remoteUrl ?? "", repository: null }; + }), + }); + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + yield* tracker.start(startInput, harness.hooks); + yield* Effect.yieldNow; + expect((yield* tracker.get(projectId))?.stalled).toBe(false); + + yield* TestClock.adjust("19 seconds"); + expect((yield* tracker.get(projectId))?.stalled).toBe(false); + yield* TestClock.adjust("1 second"); + const stalled = yield* tracker.get(projectId); + expect(stalled).toMatchObject({ stalled: true, percent: 24 }); + expect(projectCloneProgressSummary(stalled!)).toBe("Receiving objects · 24% · Stalled"); + + yield* Deferred.succeed(resumed, undefined); + yield* Effect.yieldNow; + expect(yield* tracker.get(projectId)).toMatchObject({ stalled: false, percent: 25 }); + + yield* TestClock.adjust("20 seconds"); + expect((yield* tracker.get(projectId))?.stalled).toBe(true); + yield* Deferred.succeed(release, undefined); + yield* Effect.yieldNow; + expect(yield* tracker.get(projectId)).toMatchObject({ phase: "done", stalled: false }); + }).pipe(Effect.provide(harness.layer)); + }); + it.effect("keeps a failed clone with git's own explanation and retries it", () => { let attempts = 0; const harness = makeHarness({ diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index 9d8896cea0fa3..411d84fb38592 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -67,6 +68,10 @@ export interface ProjectCloneHooks { } const DONE_RETENTION = "30 seconds"; +// Git prints nothing while a transfer waits on the network, and the kernel can +// take 15+ minutes to give up on a dead connection. Without this the banner +// keeps showing the last transfer speed as if data were still arriving. +const STALL_AFTER = "20 seconds"; function clampText(text: string, maxLength: number): string { return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`; @@ -165,9 +170,28 @@ export const make = Effect.gen(function* () { percent: update.percent, detail: update.detail === null ? null : clampText(update.detail, PROJECT_CLONE_DETAIL_MAX_LENGTH), + stalled: false, }, })).pipe(Effect.asVoid); + const markStalled = (projectId: ProjectId) => + modify(projectId, (tracked) => ({ + ...tracked, + snapshot: { ...tracked.snapshot, stalled: true }, + })).pipe(Effect.asVoid); + + /** Marks the clone stalled once, then waits for git to speak again. */ + const watchForStall = (projectId: ProjectId, pulses: Queue.Dequeue) => + Queue.take(pulses).pipe( + Effect.timeoutOption(STALL_AFTER), + Effect.flatMap((pulse) => + Option.isSome(pulse) + ? Effect.void + : markStalled(projectId).pipe(Effect.andThen(Queue.take(pulses))), + ), + Effect.forever, + ); + const finish = Effect.fn("ProjectCloneTracker.finish")(function* ( projectId: ProjectId, phase: "done" | "failed" | "cancelled", @@ -182,12 +206,29 @@ export const make = Effect.gen(function* () { phase, endedAt, percent: phase === "done" ? 100 : tracked.snapshot.percent, + stalled: false, error: error === null ? null : clampText(error, PROJECT_CLONE_ERROR_MAX_LENGTH), }, })); if (phase === "done") yield* scheduleRemoval(projectId); }); + const cloneWatchingForStall = Effect.fn("ProjectCloneTracker.cloneWatchingForStall")(function* ( + projectId: ProjectId, + tracked: TrackedClone, + ) { + const pulses = yield* Queue.sliding(1); + yield* watchForStall(projectId, pulses).pipe(Effect.forkScoped); + return yield* repositories.cloneRepository( + { remoteUrl: tracked.input.cloneUrl, destinationPath: tracked.input.destinationPath }, + { + onProgress: (update) => + Queue.offer(pulses, undefined).pipe(Effect.andThen(progress(projectId, update))), + timeoutMs: null, + }, + ); + }, Effect.scoped); + /** * Runs in its own fiber; the tracker records the outcome through `onExit`, * which still runs when the fiber is interrupted (a `matchCause` handler @@ -196,26 +237,21 @@ export const make = Effect.gen(function* () { * checkout. */ const runClone = (projectId: ProjectId, tracked: TrackedClone) => - repositories - .cloneRepository( - { remoteUrl: tracked.input.cloneUrl, destinationPath: tracked.input.destinationPath }, - { onProgress: (update) => progress(projectId, update), timeoutMs: null }, - ) - .pipe( - Effect.onExit((exit) => - Exit.isSuccess(exit) - ? finish(projectId, "done", null) - : Cause.hasInterruptsOnly(exit.cause) - ? finish(projectId, "cancelled", null) - : finish(projectId, "failed", describeCloneFailure(exit.cause)), - ), - Effect.flatMap(() => - tracked.hooks - .onCloned({ projectId, workspaceRoot: tracked.input.destinationPath }) - .pipe(Effect.ignoreCause({ log: true })), - ), - Effect.ignoreCause(), - ); + cloneWatchingForStall(projectId, tracked).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? finish(projectId, "done", null) + : Cause.hasInterruptsOnly(exit.cause) + ? finish(projectId, "cancelled", null) + : finish(projectId, "failed", describeCloneFailure(exit.cause)), + ), + Effect.flatMap(() => + tracked.hooks + .onCloned({ projectId, workspaceRoot: tracked.input.destinationPath }) + .pipe(Effect.ignoreCause({ log: true })), + ), + Effect.ignoreCause(), + ); const launch = Effect.fn("ProjectCloneTracker.launch")(function* (projectId: ProjectId) { const current = yield* Ref.get(clones); @@ -244,6 +280,7 @@ export const make = Effect.gen(function* () { stage: "connecting", percent: null, detail: null, + stalled: false, error: null, startedAt, endedAt: null, @@ -346,6 +383,7 @@ export const make = Effect.gen(function* () { stage: "connecting", percent: null, detail: null, + stalled: false, error: null, startedAt, endedAt: null, diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 2fce68ed27b9f..6fc57297a40e0 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -261,6 +261,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.displayName).toBe("T3Tools/t3code"); expect(identity?.origin).toEqual({ canonicalKey: "github.com/julius/t3code-fork", + remoteUrl: "git@github.com:julius/t3code-fork.git", displayName: "julius/t3code-fork", }); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index e3f3674c11e64..0ec948b485fc0 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -105,7 +105,7 @@ function buildRepositoryOrigin( const originKey = normalizeGitRemoteUrl(originUrl); if (originKey === canonicalKey) return undefined; const displayName = repositoryPathOf(originKey, originUrl); - return { canonicalKey: originKey, ...(displayName ? { displayName } : {}) }; + return { canonicalKey: originKey, remoteUrl: originUrl, ...(displayName ? { displayName } : {}) }; } function buildRepositoryIdentity(input: { diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index b6682d974b7e6..5452560d28917 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -76,6 +76,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { nativeUpdate: { args: ["update"], isCommandPath: isCodexStandaloneCommandPath, + requiresCommandPath: true, env: { CODEX_HOME: sharedHomePath }, }, }); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index f91e63047cc4c..01a3bdc63b931 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -97,6 +97,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => customModels: [], launchArgs: "", autoCompactWindow: "", + idleCompaction: "default", ...overrides, }); diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 54be975b18346..e62f661fcfc2c 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-19T07:21:38Z", + "updatedAt": "2026-10-21T06:20:00Z", "compatibility": [ { "driver": "codex", @@ -134,7 +134,9 @@ { "slug": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "status": "legacy" }, { "slug": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "status": "legacy" }, { "slug": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "status": "legacy" }, - { "slug": "gpt-5.5", "name": "GPT-5.5", "status": "legacy" } + { "slug": "gpt-5.5", "name": "GPT-5.5", "status": "legacy" }, + { "slug": "gpt-5.4-mini", "name": "GPT-5.4 Mini", "status": "legacy" }, + { "slug": "gpt-5.3-codex-spark", "name": "GPT-5.3 Codex Spark", "status": "legacy" } ] }, "claudeAgent": { diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index de373771345bc..43808ff37df87 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -454,14 +454,43 @@ describe("providerMaintenanceRunner", () => { const updateState = result.providers[0]?.updateState; assert.strictEqual(updateState?.status, "failed"); - assert.strictEqual(updateState?.message, "Update command exited with code 1."); - assert.include(updateState?.output ?? "", "permission denied"); + assert.strictEqual( + updateState?.message, + "Update command exited with code 1: Error: Could not detect the Codex installation method.", + ); + assert.include(updateState?.output ?? "", "checking install"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("0.0.0"), + layerMockSpawner(() => ({ + stdout: "checking install\n", + stderr: "Error: Could not detect the Codex installation method.\n\n", + code: 1, + })), + ), + ), + ), + ); + + it.effect("keeps the exit code message when a failed command prints nothing", () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry(); + const updater = yield* makeTestRunner(registry); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + + assert.strictEqual( + result.providers[0]?.updateState?.message, + "Update command exited with code 1.", + ); }).pipe( Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner(() => ({ stderr: "permission denied", code: 1 })), + layerMockSpawner(() => ({ code: 1 })), ), ), ), diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 7b59e1dea946f..43d8b1e3916ad 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -242,14 +242,30 @@ function commandOutput(result: ProviderMaintenanceCommandResult): string | null return truncateText(output, UPDATE_OUTPUT_MAX_BYTES); } +// Updaters print their reason last, usually on stderr. +function lastOutputLine(result: ProviderMaintenanceCommandResult): string | null { + for (const output of [result.stderr, result.stdout]) { + const line = output + .split(/\r\n|\r|\n/) + .map(toProgressLine) + .findLast(Boolean); + if (line) { + return line; + } + } + return null; +} + function failureMessage(result: ProviderMaintenanceCommandResult): string { if (result.timedOut) { return "Update timed out."; } - if (result.exitCode !== null && result.exitCode !== 0) { - return `Update command exited with code ${result.exitCode}.`; - } - return "Update command failed."; + const summary = + result.exitCode !== null && result.exitCode !== 0 + ? `Update command exited with code ${result.exitCode}` + : "Update command failed"; + const reason = lastOutputLine(result); + return reason ? `${summary}: ${reason}` : `${summary}.`; } function isOutdatedProvider(provider: ServerProvider | undefined): boolean { @@ -474,6 +490,13 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const result = yield* runMaintenanceCommand(command, setRunningMessage); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { + yield* Effect.logWarning("Provider update command failed", { + provider, + command: describeCommand(command), + exitCode: result.exitCode, + timedOut: result.timedOut, + output: commandOutput(result), + }); return yield* finish( makeUpdateState({ status: "failed", diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 2374a37692f10..4bc3a16c4259c 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2683,6 +2683,74 @@ it.effect( }), ); +it.effect("notifies linked threads when detail changes draft status in either direction", () => + Effect.gen(function* () { + let isDraft = true; + let updatedAt = "2026-07-02T00:00:00Z"; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider(SourceControlProviderKind.make("github"), { + getChangeRequest: () => + Effect.sync(() => ({ ...hostedChangeRequest("body"), isDraft, updatedAt })), + }), + ], + }); + const changes = yield* service.subscribeStateChanges; + const read = () => service.detail({ ...reference, allowStale: false }); + yield* read(); + yield* read(); + isDraft = false; + yield* service.invalidate({ reference }); + yield* read(); + yield* read(); + isDraft = true; + yield* service.invalidate({ reference }); + yield* read(); + + isDraft = false; + updatedAt = "2026-07-01T00:00:00Z"; + yield* service.invalidate({ reference }); + yield* read(); + + // A different PR ends collection after all the synchronous notifications above. + yield* service.reportState({ reference: { ...reference, number: 2 }, state: "open" }); + const events = yield* Stream.runCollect( + changes.pipe(Stream.takeUntil((event) => event.number === 2, { excludeLast: true })), + ); + assert.deepStrictEqual(events, [ + { host: "github.com", repository: "acme/web", number: 1 }, + { host: "github.com", repository: "acme/web", number: 1 }, + { host: "github.com", repository: "acme/web", number: 1 }, + ]); + }), +); + +it.effect("deduplicates routed PR reports without dropping draft changes", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], + }); + const changes = yield* service.subscribeStateChanges; + for (const isDraft of [true, true, false, false, true]) { + yield* service.reportState({ reference, state: "open", isDraft }); + } + yield* service.reportState({ reference, state: "open" }); + yield* service.reportState({ reference, state: "open" }); + yield* service.reportState({ reference: { ...reference, number: 2 }, state: "open" }); + const events = yield* Stream.runCollect( + changes.pipe(Stream.takeUntil((event) => event.number === 2, { excludeLast: true })), + ); + assert.deepStrictEqual( + events.map((event) => event.number), + [1, 1, 1, 1], + ); + }), +); + it.effect("reuses an observed merged state for strict settlement reads", () => Effect.gen(function* () { const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 48aa500a9db93..45fbe69bc3f30 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -107,7 +107,10 @@ export interface PullRequestMergeEvent extends PullRequestRef { readonly mergedAt: string; } -type PullRequestReading = Pick; +type PullRequestReading = Pick< + PullRequestSummary, + "state" | "isDraft" | "updatedAt" | "observedAt" +>; function supersedesReading(current: PullRequestReading | undefined, next: PullRequestReading) { if (current === undefined) return true; @@ -2896,10 +2899,12 @@ export const make = Effect.gen(function* () { } detailStates.set(scope, { state: next.state, + isDraft: next.isDraft, updatedAt: next.updatedAt, observedAt: next.observedAt, }); - return current?.state !== next.state && ref.host !== undefined + return (current?.state !== next.state || current?.isDraft !== next.isDraft) && + ref.host !== undefined ? PubSub.publish(stateChanges, { host: ref.host, repository: ref.repository, @@ -2911,22 +2916,34 @@ export const make = Effect.gen(function* () { // must never outrank a reading this environment made itself. const reportedStates = new Map< string, - { readonly state: PullRequestState; readonly atMs: number } + { + readonly state: PullRequestState; + readonly isDraft: boolean | undefined; + readonly atMs: number; + } >(); - const reportState: PullRequestService["Service"]["reportState"] = ({ reference, state }) => + const reportState: PullRequestService["Service"]["reportState"] = ({ + reference, + state, + isDraft, + }) => Effect.all([canonicalRef(reference), Clock.currentTimeMillis]).pipe( Effect.flatMap(([ref, nowMs]) => Effect.suspend(() => { const scope = refScope(ref); const reported = reportedStates.get(scope); - if (reported?.state === state && nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS) + if ( + reported?.state === state && + reported.isDraft === isDraft && + nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS + ) return Effect.void; reportedStates.delete(scope); if (reportedStates.size >= REF_EPOCH_CAPACITY) { const oldest = reportedStates.keys().next().value; if (oldest !== undefined) reportedStates.delete(oldest); } - reportedStates.set(scope, { state, atMs: nowMs }); + reportedStates.set(scope, { state, isDraft, atMs: nowMs }); return ref.host === undefined ? Effect.void : PubSub.publish(stateChanges, { diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts index 51ed62d08982f..c12e2e44ed7eb 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts @@ -19,6 +19,14 @@ import * as NativeTelemetryClient from "./NativeTelemetryClient.ts"; import * as ResourceAttribution from "./ResourceAttribution.ts"; import * as ResourceTelemetry from "./ResourceTelemetry.ts"; +// ResourceTelemetry classifies process.pid as the server, so fixture PIDs are +// offsets from it. Fixed PIDs collide whenever the test worker is assigned one. +const SERVER_PID = process.pid; +const CODEX_PID = SERVER_PID + 1; +const SHORT_LIVED_CHILD_PID = SERVER_PID + 2; +const ELECTRON_PID = SERVER_PID + 3; +const MONITOR_PID = SERVER_PID + 4; + function processSample( input: Partial & Pick, @@ -48,14 +56,14 @@ function nativeSnapshot(input: { }): ResourceMonitorSnapshotEvent { const processes = [ processSample({ - pid: process.pid, + pid: SERVER_PID, ppid: 1, startTimeMs: 100, cpuTimeMs: input.sequence * 10, }), processSample({ - pid: 4_242, - ppid: process.pid, + pid: CODEX_PID, + ppid: SERVER_PID, startTimeMs: 200, name: "codex", command: "codex app-server", @@ -63,7 +71,7 @@ function nativeSnapshot(input: { ioWriteBytes: input.childWriteBytes, }), processSample({ - pid: 5_000, + pid: ELECTRON_PID, ppid: 1, startTimeMs: 300, name: "electron", @@ -71,8 +79,8 @@ function nativeSnapshot(input: { cpuTimeMs: input.sequence * 20, }), processSample({ - pid: 9_000, - ppid: process.pid, + pid: MONITOR_PID, + ppid: SERVER_PID, startTimeMs: 400, name: "t3-resource-monitor", command: "t3-resource-monitor", @@ -109,7 +117,7 @@ function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot type: "desktopTelemetry", sequence: 1, sampledAtUnixMs, - electronPid: 5_000, + electronPid: ELECTRON_PID, power: { source: "electron-main", idle: "false", @@ -125,7 +133,7 @@ function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot speedLimitPercent: Option.some(90), electronProcesses: [ { - pid: 5_000, + pid: ELECTRON_PID, creationTimeMs: 300, type: "Browser", name: "electron", @@ -152,17 +160,17 @@ describe("ResourceTelemetry", () => { childWriteBytes: index * 100, }); const processes = sample.processes - .filter((entry) => entry.pid === process.pid || entry.pid === 4_242) + .filter((entry) => entry.pid === SERVER_PID || entry.pid === CODEX_PID) .map((entry) => - entry.pid === 4_242 + entry.pid === CODEX_PID ? { ...entry, startTimeMs: index } : { ...entry, ioWriteBytes: index * 100 }, ); if (index === 0 || index === 2) { processes.push( processSample({ - pid: 4_243, - ppid: process.pid, + pid: SHORT_LIVED_CHILD_PID, + ppid: SERVER_PID, startTimeMs: 500, cpuTimeMs: index === 0 ? 100 : 350, ioWriteBytes: index === 0 ? 1_000 : 5_000, @@ -190,18 +198,20 @@ describe("ResourceTelemetry", () => { expect(history.topProcesses).toHaveLength(sampleCount + 2); expect( - history.topProcesses.find((entry) => entry.identity.pid === process.pid), + history.topProcesses.find((entry) => entry.identity.pid === SERVER_PID), ).toMatchObject({ sampleCount, cpuTimeMs: (sampleCount - 1) * 10, ioWriteBytes: (sampleCount - 1) * 100, }); - const children = history.topProcesses.filter((entry) => entry.identity.pid === 4_242); + const children = history.topProcesses.filter((entry) => entry.identity.pid === CODEX_PID); expect(children).toHaveLength(sampleCount); for (const child of children) { expect(child).toMatchObject({ sampleCount: 1, cpuTimeMs: 0, ioWriteBytes: 0 }); } - expect(history.topProcesses.find((entry) => entry.identity.pid === 4_243)).toMatchObject({ + expect( + history.topProcesses.find((entry) => entry.identity.pid === SHORT_LIVED_CHILD_PID), + ).toMatchObject({ sampleCount: 2, cpuTimeMs: 250, ioWriteBytes: 4_000, @@ -224,7 +234,7 @@ describe("ResourceTelemetry", () => { sampledAtUnixMs, childCpuTimeMs: 100, childWriteBytes: 1_000, - externalProcesses: [{ pid: 5_000, startTimeMs: 300 }], + externalProcesses: [{ pid: ELECTRON_PID, startTimeMs: 300 }], }); const desktop = { ...desktopSnapshot(sampledAtUnixMs), @@ -253,9 +263,9 @@ describe("ResourceTelemetry", () => { return yield* telemetry.refresh; }).pipe(Effect.provide(layerTelemetry)); - expect(snapshot.processes.find((entry) => entry.identity.pid === 5_000)?.category).toBe( - "electron-main", - ); + expect( + snapshot.processes.find((entry) => entry.identity.pid === ELECTRON_PID)?.category, + ).toBe("electron-main"); expect(snapshot.groups.electron.processStarts).toBe(1); expect(snapshot.groups.backend.processStarts).toBe(3); }), diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index bcb110a5da71b..09e1d7674044f 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -116,3 +116,54 @@ it.effect("preserves a due run when a save only pads the scheduled hour", () => }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("keeps the task list order when a toggle or edit rewrites updated_at", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z")); + const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const input = (id: string) => ({ + id, + title: id, + prompt: "Review the open pull requests.", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: "project-stable-order", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + const listedIds = service + .list() + .pipe(Effect.map(({ tasks }) => tasks.map((task) => String(task.id)))); + + const older = yield* service.upsert(yield* decodeUpsertInput(input("scheduled-task:older"))); + yield* TestClock.adjust("1 second"); + yield* service.upsert(yield* decodeUpsertInput(input("scheduled-task:newer"))); + const order = ["scheduled-task:newer", "scheduled-task:older"]; + expect(yield* listedIds).toEqual(order); + + yield* TestClock.adjust("1 second"); + const toggled = yield* service.setEnabled({ id: older.task.id, enabled: false }); + expect(toggled.task.updatedAt > older.task.updatedAt).toBe(true); + expect(yield* listedIds).toEqual(order); + + yield* TestClock.adjust("1 second"); + const edit = { ...input(older.task.id), requireExisting: true, title: "Edited" }; + yield* service.upsert(yield* decodeUpsertInput({ ...edit, enabled: false })); + expect(yield* listedIds).toEqual(order); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 01066910906e5..6cc96248cb6e9 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -477,7 +477,9 @@ export const layer = Layer.effect( webhook_token, webhook_secret FROM scheduled_tasks - ORDER BY updated_at DESC, task_id ASC + -- Not updated_at: toggles, runs and edits rewrite it, and clients render + -- this order as-is, so rows would move under the pointer. + ORDER BY created_at DESC, task_id ASC `; const listRows = Effect.fn("ScheduledTaskService.listRows")(function* () { diff --git a/apps/server/src/scheduledTasks/webhookFilter.test.ts b/apps/server/src/scheduledTasks/webhookFilter.test.ts new file mode 100644 index 0000000000000..1194a1aed5177 --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookFilter.test.ts @@ -0,0 +1,95 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { findFailedWebhookFilter } from "./webhookFilter.ts"; +import type { WebhookRequest } from "./webhookTemplate.ts"; + +const issueComment: WebhookRequest = { + method: "POST", + path: "/api/hooks/task-1", + query: "", + headers: { "content-type": "application/json", "x-github-event": "issue_comment" }, + bodyText: JSON.stringify({ + action: "created", + issue: { number: 7, labels: [{ name: "bug" }, { name: "needs more info" }], assignees: [] }, + comment: { user: { login: "reporter", type: "User" } }, + }), +}; + +describe("findFailedWebhookFilter", () => { + it("matches a wildcard path when any element matches", () => { + assert.isNull( + findFailedWebhookFilter( + [ + { path: "body.issue.labels[].name", op: "equals", value: "needs more info" }, + { path: "body.issue.labels[].name", op: "in", value: ["needs more info", "triage"] }, + { path: "body.issue.labels[].name", op: "startsWith", value: "needs" }, + { path: "body.issue.labels[].name", op: "exists" }, + ], + issueComment, + ), + ); + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "equals", value: "wontfix" }], + issueComment, + ), + { index: 0, path: "body.issue.labels[].name" }, + ); + }); + + it("fails notEquals on a wildcard path when any element equals the value", () => { + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "notEquals", value: "bug" }], + issueComment, + ), + { index: 0, path: "body.issue.labels[].name" }, + ); + assert.isNull( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "notEquals", value: "wontfix" }], + issueComment, + ), + ); + }); + + it("treats an empty or missing wildcard list as nothing to match", () => { + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.assignees[].login", op: "exists" }], + issueComment, + ), + { index: 0, path: "body.issue.assignees[].login" }, + ); + assert.isNull( + findFailedWebhookFilter( + [{ path: "body.issue.assignees[].login", op: "notEquals", value: "anyone" }], + issueComment, + ), + ); + assert.deepEqual( + findFailedWebhookFilter([{ path: "body.issue.number[].x", op: "exists" }], issueComment), + { index: 0, path: "body.issue.number[].x" }, + ); + }); + + it("still compares scalars without a wildcard", () => { + assert.isNull( + findFailedWebhookFilter( + [ + { path: "body.action", op: "equals", value: "created" }, + { path: "body.comment.user.type", op: "notEquals", value: "Bot" }, + ], + issueComment, + ), + ); + // A list without a wildcard in the path only satisfies exists. + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels", op: "equals", value: "bug" }], + issueComment, + ), + { index: 0, path: "body.issue.labels" }, + ); + }); +}); diff --git a/apps/server/src/scheduledTasks/webhookFilter.ts b/apps/server/src/scheduledTasks/webhookFilter.ts index 6f880ef7cebe6..882f300364288 100644 --- a/apps/server/src/scheduledTasks/webhookFilter.ts +++ b/apps/server/src/scheduledTasks/webhookFilter.ts @@ -13,11 +13,7 @@ function scalarText(value: unknown): string | undefined { return undefined; } -/** Placeholders render null as missing, so filters treat it the same way. */ -function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean { - if (filter.op === "exists") return value !== undefined && value !== null; - const text = scalarText(value); - if (text === undefined) return false; +function matchesScalar(filter: ScheduledTaskWebhookFilter, text: string): boolean { switch (filter.op) { case "equals": return text === filter.value; @@ -27,9 +23,29 @@ function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown return filter.value.includes(text); case "startsWith": return text.startsWith(filter.value); + case "exists": + return true; } } +/** + * Placeholders render null as missing, so filters treat it the same way. A + * path with a `[]` wildcard resolves to a list: `equals`, `in` and + * `startsWith` match when any element does, `notEquals` only when none equals + * the value, and `exists` when the list has an element. + */ +function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean { + if (Array.isArray(value) && /\[\]/.test(filter.path)) { + const texts = value.map(scalarText).filter((text): text is string => text !== undefined); + if (filter.op === "notEquals") return texts.every((text) => text !== filter.value); + return texts.some((text) => matchesScalar(filter, text)); + } + if (filter.op === "exists") return value !== undefined && value !== null; + const text = scalarText(value); + if (text === undefined) return false; + return matchesScalar(filter, text); +} + /** The first filter the request fails, or null when every filter matches. */ export function findFailedWebhookFilter( filters: ReadonlyArray, diff --git a/apps/server/src/scheduledTasks/webhookTemplate.test.ts b/apps/server/src/scheduledTasks/webhookTemplate.test.ts index b5ac2e69aa38f..6aa23e12b0363 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.test.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.test.ts @@ -165,6 +165,23 @@ describe("renderWebhookPrompt", () => { ); }); + it("renders a [] wildcard as the list of matching values", () => { + const rendered = renderWebhookPrompt( + "Labels: {{body.pull_request.labels[]}} Names: {{body.reviewers[].login}}", + { + ...githubPullRequest, + bodyText: JSON.stringify({ + pull_request: { labels: ["bug", "ui"] }, + reviewers: [{ login: "ada" }, { name: "no login" }, { login: "bob" }], + }), + }, + "id", + true, + ); + assert.equal(rendered.prompt, 'Labels: [\n "bug",\n "ui"\n] Names: [\n "ada",\n "bob"\n]'); + assert.deepEqual(rendered.missing, []); + }); + it("does not resolve inherited object properties", () => { const rendered = renderWebhookPrompt( "{{body.constructor}}{{body.__proto__}}{{headers.constructor}}{{headers.__proto__}}", diff --git a/apps/server/src/scheduledTasks/webhookTemplate.ts b/apps/server/src/scheduledTasks/webhookTemplate.ts index 063d41a7ec462..84e2599dc0d75 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.ts @@ -83,12 +83,30 @@ function parseBody(request: WebhookRequest): unknown { } } +const WILDCARD = /^(.*)\[\]$/; + +/** + * Walks `segments` from `root`. A segment ending in `[]` names an array and + * the rest of the path applies to each element, so `labels[].name` resolves + * to the list of every label's name. Elements without the rest of the path + * are dropped; a wildcard on a non-array resolves to undefined. + */ function lookup(root: unknown, segments: ReadonlyArray): unknown { let current = root; - for (const segment of segments) { + for (const [index, segment] of segments.entries()) { if (current === null || typeof current !== "object") return undefined; - if (!Object.hasOwn(current, segment)) return undefined; - current = (current as Record)[segment]; + const wildcard = WILDCARD.exec(segment); + const key = wildcard ? wildcard[1]! : segment; + if (!Object.hasOwn(current, key)) return undefined; + current = (current as Record)[key]; + if (!wildcard) continue; + if (!Array.isArray(current)) return undefined; + const rest = segments.slice(index + 1); + return current.flatMap((element) => { + const value = rest.length === 0 ? element : lookup(element, rest); + if (value === undefined) return []; + return rest.some((part) => WILDCARD.test(part)) && Array.isArray(value) ? value : [value]; + }); } return current; } diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 00fabc7004fb8..992e219db9122 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -244,7 +244,7 @@ const cleanupFixture = ( const fs = yield* FileSystem.FileSystem; const config = yield* ServerConfig.ServerConfig; const git = yield* GitVcsDriver.GitVcsDriver; - const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); + const repo = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" })); const command = (cwd: string, args: string[]) => git.execute({ operation: "cleanup-test", cwd, args }); yield* command(repo, ["init", "-b", "main"]); @@ -276,7 +276,12 @@ const cleanupFixture = ( ...git, execute: (input) => Effect.gen(function* () { - if (editBeforeRemoval && input.args.includes("remove")) + // Before Git's own clean check: inside `git worktree remove`, or on + // Windows the same status check the removal makes before deleting. + if ( + editBeforeRemoval && + (input.args.includes("remove") || input.args.includes("--porcelain")) + ) yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); return yield* git.execute(input); }), @@ -1241,6 +1246,89 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live.each(["same-device", "other-device", "no-manifest"] as const)( + "counts the pnpm virtual store only when its store forces copies (%s)", + (store) => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const storeDir = yield* fs.makeTempDirectoryScoped({ prefix: "pnpm-store-" }); + const { service, worktree, setPolicy } = yield* cleanupFixture().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stat: (target) => + fs + .stat(target) + .pipe( + Effect.map((info) => + store === "other-device" && target === storeDir + ? { ...info, dev: info.dev + 1n } + : info, + ), + ), + }), + ); + setPolicy("tracked-changes"); + const nodeModules = `${worktree}/node_modules`; + yield* fs.makeDirectory(`${nodeModules}/.pnpm/pkg@1.0.0`, { recursive: true }); + yield* fs.makeDirectory(`${nodeModules}/.vite`); + yield* fs.writeFileString( + `${nodeModules}/.pnpm/pkg@1.0.0/index.js`, + "clone ".repeat(10_000), + ); + yield* fs.writeFileString(`${nodeModules}/.vite/deps.js`, "cache"); + if (store !== "no-manifest") + yield* fs.writeFileString( + `${nodeModules}/.modules.yaml`, + JSON.stringify({ storeDir, virtualStoreDir: ".pnpm" }), + ); + const counted = [ + ...(yield* fs.readDirectory(worktree)).flatMap((name) => + name === "node_modules" ? [] : [`${worktree}/${name}`], + ), + `${nodeModules}/.vite/deps.js`, + ...(store === "no-manifest" ? [] : [`${nodeModules}/.modules.yaml`]), + ...(store === "same-device" ? [] : [`${nodeModules}/.pnpm/pkg@1.0.0/index.js`]), + ]; + const bytes = (yield* Effect.forEach(counted, (file) => allocatedBytes(fs, file))).reduce( + (sum, size) => sum + size, + 0, + ); + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes }); + }), + ), + ); + // An agent can write this manifest. Statting its store sent the Windows + // user's NTLM credentials to attacker.test during a background sweep. + it.live.each([ + { storeDir: "\\\\attacker.test\\s" }, + { storeDir: "/local/store", virtualStoreDir: "//attacker.test/s" }, + ])("never stats a network pnpm store %o", (manifest) => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const statted: Array = []; + const { service, worktree, setPolicy } = yield* cleanupFixture().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stat: (target) => { + statted.push(target); + return fs.stat(target); + }, + }), + ); + setPolicy("tracked-changes"); + yield* fs.makeDirectory(`${worktree}/node_modules`); + yield* fs.writeFileString( + `${worktree}/node_modules/.modules.yaml`, + JSON.stringify(manifest), + ); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(statted.filter((target) => target.includes("attacker.test"))).toEqual([]); + }), + ), + ); it.live("still removes a worktree when measuring its size fails", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index a4d1e831d9007..a196321e38098 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -32,6 +32,7 @@ import type { PlatformError } from "effect/PlatformError"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import { parse as parseYaml } from "yaml"; import * as ServerConfig from "./config.ts"; import * as GitManager from "./git/GitManager.ts"; @@ -45,9 +46,11 @@ import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import { removeLinkedWorktree } from "./vcs/worktreeRemoval.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { isNetworkPath } from "@t3tools/shared/path"; const decodeCleanupThread = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2AppThreadJson), @@ -55,6 +58,13 @@ const decodeCleanupThread = Schema.decodeUnknownEffect( const decodeCleanupSession = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2ProviderSessionJson), ); +// pnpm 11 writes this file as JSON, older versions as YAML. +const decodePnpmModulesManifest = Schema.decodeUnknownEffect( + Schema.Struct({ + storeDir: Schema.String, + virtualStoreDir: Schema.optional(Schema.String), + }), +); const DAY_MS = 86_400_000; // After a settle, the project's settle script needs time to open its @@ -247,14 +257,40 @@ const make = Effect.gen(function* () { ); }); + // pnpm imports packages by clone or reflink when its store shares a device + // with the worktree. Those files have one link but share blocks with the + // store, so removing them frees almost nothing. A store on another device + // forces real copies, which still count. + const sharedPnpmVirtualStore = Effect.fnUntraced( + function* (root: string) { + const nodeModules = path.join(root, "node_modules"); + const text = yield* fs.readFileString(path.join(nodeModules, ".modules.yaml")); + const manifest = yield* decodePnpmModulesManifest(yield* Effect.try(() => parseYaml(text))); + // An agent can write this manifest, and statting a network path makes + // Windows sign in to its host with the user's NTLM credentials. + if (isNetworkPath(manifest.storeDir) || isNetworkPath(manifest.virtualStoreDir ?? "")) { + return null; + } + const virtualStore = path.resolve(nodeModules, manifest.virtualStoreDir ?? ".pnpm"); + const [store, virtual] = yield* Effect.all([ + fs.stat(manifest.storeDir), + fs.stat(virtualStore), + ]); + return store.dev === virtual.dev ? virtualStore : null; + }, + Effect.orElseSucceed(() => null), + ); + const measureWorktree = Effect.fn("storageCleanup.measureWorktree")( function* (worktreePath: string) { const root = yield* fs.realPath(worktreePath); + const skipped = yield* sharedPnpmVirtualStore(root); const pending = [root]; let entries = 1; let bytes = 0; while (pending.length > 0) { const target = pending.pop()!; + if (target === skipped) continue; // Effect's stat follows links. Probe with readLink first to get lstat // semantics, including skipping dangling links and directory links. const isLink = yield* fs.readLink(target).pipe( @@ -984,20 +1020,15 @@ const make = Effect.gen(function* () { const changes = yield* localChangesReason(worktreePath, settings); if (changes !== null) return keep(changes); } - yield* cleanupGit( - project.workspaceRoot, - [ - "-c", - "status.showUntrackedFiles=normal", - "worktree", - "remove", - // Git requires force for submodules; recursive checks above cannot make removal atomic. - ...(submodules.force ? ["--force"] : []), - "--", - worktreePath, - ], - "git worktree remove", - ); + yield* removeLinkedWorktree({ + operation: "StorageCleanup.cleanupGit", + cwd: project.workspaceRoot, + worktreePath, + // Git requires force for submodules; recursive checks above cannot make removal atomic. + force: submodules.force, + execute: git.execute, + run: (args) => cleanupGit(project.workspaceRoot, [...args], "git worktree remove"), + }); entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); yield* gitManager.invalidateStatus(project.workspaceRoot); // Preserve branch and path: ProviderTurnStartService recreates the checkout diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 74bc1fd5ec6c8..6d82b45efb2e6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -80,6 +80,13 @@ const makeSuccessfulHandle = (stdout: string) => getOutputFd: () => Stream.empty, }); +const makeFailedHandle = (stderr: string) => + ChildProcessSpawner.makeHandle({ + ...makeNonRepositoryHandle(), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + stderr: Stream.encodeText(Stream.make(stderr)), + }); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -803,6 +810,88 @@ it.effect("retries a fetch that lost a ref update to another Git process", () => }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), ); +describe("rejected git refs", () => { + const remoteUrl = "https://user:token-123@git.example.invalid/org/secret-repo.git"; + const failedPush = `error: failed to push some refs to '${remoteUrl}'`; + const leakedText = [ + "token-123", + "secret-repo", + "git.example.invalid", + "private-branch", + "workflow scope", + ]; + + const failWith = (stderr: string) => { + const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeFailedHandle(stderr))); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(layerServerConfig), + Layer.provideMerge( + Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ), + ); + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + return yield* driver + .execute({ + operation: "GitVcsDriver.test.rejectedRef", + cwd: "/repo", + args: ["push", "origin", "HEAD"], + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(layer)); + }; + + it.effect.each([ + { + label: "a ref the remote refused", + stderr: [ + "remote: Unable to determine if workflow can be created; workflow scope may be required.", + `To ${remoteUrl}`, + " ! [remote rejected] private-branch -> private-branch (workflow scope may be required.)", + failedPush, + ], + reason: "push_rejected_by_remote" as const, + detail: "The remote rejected the push.", + }, + { + label: "a non-fast-forward push", + stderr: [ + `To ${remoteUrl}`, + " ! [rejected] private-branch -> private-branch (non-fast-forward)", + failedPush, + ], + reason: "push_rejected_non_fast_forward" as const, + detail: "Pull or rebase", + }, + ])("names $label without quoting stderr", ({ stderr, reason, detail }) => + Effect.gen(function* () { + const error = yield* failWith(stderr.join("\n")); + + assert.deepInclude(error, { reason }); + assert.include(error.detail, detail); + for (const text of leakedText) assert.notInclude(error.message, text); + }), + ); + + // A fetch into a diverged local branch prints the same rejected line, but + // never "failed to push some refs". + it.effect("leaves a rejected fetch unclassified", () => + Effect.gen(function* () { + const error = yield* failWith( + [`From ${remoteUrl}`, " ! [rejected] main -> side (non-fast-forward)"].join( + "\n", + ), + ); + + assert.equal(error.reason, undefined); + assert.equal(error.detail, "Git command exited with a non-zero status."); + }), + ); +}); + it.layer(layerTest)("GitVcsDriver core integration", (it) => { describe("structured errors", () => { it.effect("does not retain git arguments or stderr in command failures", () => @@ -1071,7 +1160,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("does not read remote hook output as a git failure reason", () => + it.effect("reads a hook-declined push from git's rejection line, not the hook's output", () => Effect.gen(function* () { const parent = yield* makeTmpDir(); const pathService = yield* Path.Path; @@ -1100,7 +1189,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }) .pipe(Effect.flip); - assert.notProperty(error, "reason"); + assert.deepInclude(error, { reason: "push_rejected_by_remote" }); assert.notInclude(error.message, "(authentication_failed)"); }), ); @@ -2144,6 +2233,66 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reads Changes totals without minimizing and keeps review diffs minimal", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "diff.algorithm", "myers"]); + // Repeated lines make the default and minimal algorithms choose different edits. + const lines = Array.from({ length: 2000 }, (_, index) => index); + yield* writeTextFile( + cwd, + "repeated.txt", + lines.map((index) => `${(index ^ (index >> 3)) % 16}\n`).join(""), + ); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "repeated lines"]); + yield* git(cwd, ["checkout", "-b", "feature/totals"]); + yield* writeTextFile( + cwd, + "repeated.txt", + lines.map((index) => `${(index ^ (index >> 5)) % 16}\n`).join(""), + ); + const defaultStat = yield* git(cwd, ["diff", "--numstat", "HEAD", "--"]); + const minimalStat = yield* git(cwd, ["diff", "--minimal", "--numstat", "HEAD", "--"]); + assert.notEqual(defaultStat, minimalStat); + const [insertions, deletions] = defaultStat.split("\t"); + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: initialBranch, + insertions: Number(insertions), + deletions: Number(deletions), + }); + + const [additions, removals] = minimalStat.split("\t"); + const minimalPatch = yield* git(cwd, [ + "diff", + "--minimal", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "HEAD", + "--", + ]); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + for (const source of preview.sources) { + assert.deepStrictEqual(source.files, [ + { + path: "repeated.txt", + previousPath: null, + additions: Number(additions), + deletions: Number(removals), + }, + ]); + assert.strictEqual(source.diff.trim(), minimalPatch); + } + }), + ); + it.effect.skipIf(!symlinksSupported)( "lists the real repository through a symlink when a decoy repo occupies the lexical common dir", () => @@ -3400,6 +3549,73 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("force-removes a worktree without deleting what its junctions point to", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, ".gitignore", "node_modules\n"); + yield* git(cwd, ["add", ".gitignore"]); + yield* git(cwd, ["commit", "-m", "ignore node_modules"]); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const outside = yield* makeTmpDir("git-worktree-link-target-"); + yield* writeTextFile(outside, "keep.txt", "keep\n"); + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "linked"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/linked", + }); + // Junctions on Windows, the way pnpm links packages; symlinks elsewhere. + NodeFS.mkdirSync(pathService.join(worktreePath, "node_modules")); + NodeFS.symlinkSync( + outside, + pathService.join(worktreePath, "node_modules", "dep"), + "junction", + ); + NodeFS.symlinkSync(outside, pathService.join(worktreePath, "shared"), "junction"); + + yield* driver.removeWorktree({ cwd, path: worktreePath, force: true }); + + assert.deepEqual(NodeFS.readdirSync(pathService.dirname(worktreePath)), []); + assert.equal( + yield* fileSystem.readFileString(pathService.join(outside, "keep.txt")), + "keep\n", + ); + }), + ); + + it.effect("keeps the junctions of a worktree that git refuses to remove", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, ".gitignore", "node_modules\n"); + yield* git(cwd, ["add", ".gitignore"]); + yield* git(cwd, ["commit", "-m", "ignore node_modules"]); + const pathService = yield* Path.Path; + const outside = yield* makeTmpDir("git-worktree-link-target-"); + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "linked"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/linked", + }); + const link = pathService.join(worktreePath, "node_modules"); + NodeFS.symlinkSync(outside, link, "junction"); + yield* writeTextFile(worktreePath, "notes.txt", "draft\n"); + + const result = yield* Effect.result(driver.removeWorktree({ cwd, path: worktreePath })); + + assert.isTrue(Result.isFailure(result)); + assert.isTrue(NodeFS.lstatSync(link).isSymbolicLink()); + assert.deepEqual(NodeFS.readdirSync(pathService.dirname(worktreePath)), ["linked"]); + }), + ); + it.effect("prunes stale registrations when removing an already-gone worktree", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e99f44a95fdd6..1de15051e9958 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -55,6 +55,7 @@ import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observabil import * as GitVcsDriver from "./GitVcsDriver.ts"; import { removeEmptyDirectory } from "./removeEmptyDirectory.ts"; import { explicitBareRepositoryEnv, gitConfigEnv } from "./VcsProcess.ts"; +import { removeLinkedWorktree } from "./worktreeRemoval.ts"; import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; import { parseRemoteNames, @@ -95,7 +96,6 @@ const REVIEW_DIFF_ARGS = [ "--no-color", "--no-ext-diff", "--no-textconv", - "--minimal", ...PATCH_RENDER_PREFIX_ARGS, ]; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); @@ -478,8 +478,32 @@ const GIT_FAILURE_REASON_PATTERNS: ReadonlyArray> = { + push_rejected_by_remote: + "The remote rejected the push. Push this branch from a terminal to see the server's message.", + push_rejected_non_fast_forward: + "The remote has commits this branch doesn't have. Pull or rebase, then push again.", +}; + +function gitFailureDetail(reason: GitCommandFailureReason | null): string { + return ( + (reason === null ? undefined : GIT_FAILURE_REASON_DETAILS[reason]) ?? + "Git command exited with a non-zero status." + ); +} + // Existing non-commit objects share the missing-ref fatal diagnostic; Git // distinguishes them with a separate error line. const GIT_NOT_A_COMMIT_PATTERN = /^error: .+(?:, not a commit|expected commit type)/im; @@ -1067,7 +1091,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* new GitCommandError({ ...gitCommandContext(commandInput), ...(reason === null ? {} : { reason }), - detail: "Git command exited with a non-zero status.", + detail: gitFailureDetail(reason), exitCode, stdoutLength: stdout.text.length, stderrLength: stderr.text.length, @@ -1159,7 +1183,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* new GitCommandError({ ...gitCommandContext({ operation, cwd, args }), ...(reason === null ? {} : { reason }), - detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", + detail: options.fallbackErrorDetail ?? gitFailureDetail(reason), ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), stdoutLength: result.stdout.length, stderrLength: result.stderr.length, @@ -3187,6 +3211,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const diffArgs = [ ...REVIEW_DIFF_ARGS, + "--minimal", ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), ]; const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* ( @@ -4314,25 +4339,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( "removeWorktree", )(function* (input) { - // Git also accepts a worktree's short name, which can match a different - // folder than the path resolved against `input.cwd`. Resolve it once so Git - // and the leftover cleanup below act on the same directory. + // Git also matches a worktree by its short name, which can name a different + // folder than this path. Resolving it once keeps Git, the Windows deletion + // in removeLinkedWorktree, and the leftover cleanup below on one directory. const target = path.resolve(input.cwd, input.path); - // Git refuses to remove a worktree with untracked files unless forced, but - // its check honors `status.showUntrackedFiles=no` and would delete them. - const args = ["-c", "status.showUntrackedFiles=normal", "worktree", "remove"]; - if (input.force) { - args.push("--force"); - } - args.push("--", target); - const result = yield* executeGitWithStableDiagnostics( - "GitVcsDriver.removeWorktree", - input.cwd, - args, - { - timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, - allowNonZeroExit: true, - }, + const result = yield* removeLinkedWorktree({ + operation: "GitVcsDriver.removeWorktree", + cwd: input.cwd, + worktreePath: target, + force: input.force === true, + execute, + run: (args) => + executeGitWithStableDiagnostics("GitVcsDriver.removeWorktree", input.cwd, args, { + timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, + allowNonZeroExit: true, + }).pipe(Effect.map((result) => ({ ...result, args }))), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), ); if (result.exitCode === 0) { // Git for Windows never descends into NTFS junctions, such as pnpm's @@ -4364,7 +4388,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // than an error. const alreadyGone = isMissingWorktreeStderr(result.stderr) && - !(yield* fileSystem.exists(input.path).pipe(Effect.orElseSucceed(() => false))); + !(yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false))); if (alreadyGone) { yield* pruneWorktrees({ cwd: input.cwd }); return; @@ -4373,7 +4397,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `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 }), + ...gitCommandContext({ + operation: "GitVcsDriver.removeWorktree", + cwd: input.cwd, + args: result.args, + }), detail: "git worktree remove failed", ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), stdoutLength: result.stdout.length, diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts new file mode 100644 index 0000000000000..c8feb71c3d845 --- /dev/null +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -0,0 +1,164 @@ +import { GitCommandError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +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 type * as GitVcsDriver from "./GitVcsDriver.ts"; + +const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024; + +/** + * Removes the linked worktree at `worktreePath` by running `git worktree + * remove` through `run`. + * + * Git for Windows before 2.54 follows NTFS junctions while it deletes a + * worktree, and deletes what they point to, such as a shared node_modules. + * Later versions skip junctions but leave them, and the folders holding them, + * behind. On Windows, Git therefore never deletes the files: this makes the + * checks `git worktree remove` makes before deleting (builtin/worktree.c), + * deletes the folder with Node's `rm`, which unlinks junctions without + * following them, and then has Git drop the now-missing worktree's metadata. + * Git elsewhere only unlinks links, so other hosts run Git as before. + */ +export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorktree")(function* < + A, + E, + R, +>(input: { + readonly operation: string; + readonly cwd: string; + readonly worktreePath: string; + readonly force: boolean; + readonly execute: GitVcsDriver.GitVcsDriver["Service"]["execute"]; + readonly run: (args: ReadonlyArray) => Effect.Effect; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreePath = input.worktreePath; + const gitFile = path.join(worktreePath, ".git"); + // Git refuses to remove a worktree with untracked files unless forced, but + // its check honors `status.showUntrackedFiles=no` and would delete them. + const statusConfig = ["-c", "status.showUntrackedFiles=normal"]; + const linked = + (yield* HostProcess.isWindows) && + (yield* fileSystem.stat(gitFile).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + )); + if (!linked) { + return yield* input.run([ + ...statusConfig, + "worktree", + "remove", + ...(input.force ? ["--force"] : []), + "--", + worktreePath, + ]); + } + + // Git's own refusals, worded the way it prints them. + const refuse = (message: string, cause?: unknown) => + new GitCommandError({ + operation: input.operation, + command: "git worktree remove", + cwd: input.cwd, + detail: `fatal: ${message}`, + ...(cause === undefined ? {} : { cause }), + }); + const gitOutput = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + input + .execute({ + operation: input.operation, + cwd, + args, + maxOutputBytes: INDEX_LIST_MAX_BYTES, + ...(env === undefined ? {} : { env }), + }) + .pipe(Effect.map((result) => result.stdout)); + const samePath = (left: string, right: string) => + Effect.all([fileSystem.realPath(left), fileSystem.realPath(right)]).pipe( + Effect.map(([a, b]) => a.toLowerCase() === b.toLowerCase()), + Effect.orElseSucceed(() => false), + ); + + // A registered worktree of this repository: its `.git` file names an admin + // directory under the common `worktrees/`, whose `gitdir` points back. + const notAWorktree = refuse(`'${worktreePath}' is not a working tree`); + const adminDir = path.resolve( + worktreePath, + (yield* gitOutput(worktreePath, ["rev-parse", "--absolute-git-dir"]).pipe( + Effect.mapError(() => notAWorktree), + )).trim(), + ); + const commonDir = path.resolve( + input.cwd, + (yield* gitOutput(input.cwd, ["rev-parse", "--git-common-dir"])).trim(), + ); + const pointsBack = yield* fileSystem.readFileString(path.join(adminDir, "gitdir")).pipe( + Effect.flatMap((gitdir) => samePath(path.resolve(adminDir, gitdir.trim()), gitFile)), + Effect.orElseSucceed(() => false), + ); + if (!pointsBack || !(yield* samePath(path.dirname(adminDir), path.join(commonDir, "worktrees")))) + return yield* notAWorktree; + // Git counts the file's existence as a lock. T3 passes at most one + // `--force`, which never removes a locked worktree. Anything but a + // confirmed absence refuses. + const lockFile = path.join(adminDir, "locked"); + const locked = yield* fileSystem.stat(lockFile).pipe( + Effect.as(true), + Effect.catch((error) => + error.reason._tag === "NotFound" + ? Effect.succeed(false) + : Effect.fail(refuse(`cannot inspect the lock of '${worktreePath}'`, error)), + ), + ); + if (locked) { + const reason = (yield* fileSystem.readFileString(lockFile).pipe(Effect.option)).pipe( + Option.map((text) => text.trim()), + Option.getOrElse(() => ""), + ); + return yield* refuse( + reason === "" + ? "cannot remove a locked working tree;\nuse 'remove -f -f' to override or unlock first" + : `cannot remove a locked working tree, lock reason: ${reason}\nuse 'remove -f -f' to override or unlock first`, + ); + } + if (!input.force) { + // Bound like Git's own check, so `core.worktree` can't point it elsewhere. + const boundEnv = { GIT_DIR: gitFile, GIT_WORK_TREE: worktreePath }; + const submodules = refuse("working trees containing submodules cannot be moved or removed"); + // An unreadable path counts as present, so a doubt refuses. + const present = (target: string) => + fileSystem.exists(target).pipe(Effect.orElseSucceed(() => true)); + if (yield* present(path.join(adminDir, "modules"))) return yield* submodules; + const index = yield* gitOutput(worktreePath, ["ls-files", "--stage", "-z"], boundEnv); + for (const entry of index.split("\0")) { + if (!entry.startsWith("160000 ")) continue; + const submodule = entry.slice(entry.indexOf("\t") + 1); + if (yield* present(path.join(worktreePath, submodule, ".git"))) return yield* submodules; + } + const status = yield* gitOutput( + worktreePath, + [...statusConfig, "status", "--porcelain", "--ignore-submodules=none"], + boundEnv, + ); + if (status !== "") + return yield* refuse( + `'${worktreePath}' contains modified or untracked files, use --force to delete it`, + ); + } + + // A file held open can stop the delete partway. Putting `.git` back, when + // that works, leaves a worktree that a retry can remove. + const gitFileContents = yield* fileSystem + .readFileString(gitFile) + .pipe(Effect.mapError(() => notAWorktree)); + yield* fileSystem.remove(worktreePath, { recursive: true }).pipe( + Effect.tapError(() => fileSystem.writeFileString(gitFile, gitFileContents).pipe(Effect.ignore)), + Effect.mapError((cause) => refuse(`failed to delete '${worktreePath}'`, cause)), + ); + // The folder is gone, so Git only drops the worktree's metadata. + return yield* input.run(["worktree", "remove", "--force", "--force", "--", worktreePath]); +}); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 6dc5d4c085754..9eb2ba7580490 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,6 +1,8 @@ -// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO. +// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO, and reads spy on native realpath. import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -17,6 +19,12 @@ import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { vi } from "vite-plus/test"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, realpath: vi.fn(actual.realpath) }; +}); // Temp folders count as known workspaces unless named as unknown. const UNKNOWN_WORKSPACE_PREFIX = "t3code-unknown-workspace-"; @@ -123,6 +131,35 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i ]); }), ); + + // A file chip for `file://attacker.test/s/a.ts` in an agent's message sent + // the Windows user's NTLM credentials to attacker.test as it rendered. + it.effect("never stats a network path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const statted: Array = []; + const service = yield* WorkspaceFileSystem.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fs, + stat: (requestedPath) => { + statted.push(requestedPath); + return fs.stat(requestedPath); + }, + }), + ), + Effect.provide(layerKnownWorkspacesTest), + ); + const paths = [ + "\\\\attacker.test\\s\\a.ts", + "//attacker.test/s/a.ts", + "/\\attacker.test\\s", + ]; + expect((yield* service.getMetadata({ paths })).entries).toEqual([null, null, null]); + expect(statted).toEqual([]); + }), + ); }); describe("readFile", () => { @@ -199,6 +236,68 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("refuses a network path outside the workspace", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + + const error = yield* workspaceFileSystem + .readFile({ cwd: "//nas.test/share/project", relativePath: "//attacker.test/s/a.md" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }), + ); + + // This resolves inside the project, but Windows reads `\\?\` paths as + // written, so realpath on the raw string signed in to attacker.test. + it.effect("reads the network path it checked", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.make.pipe( + Effect.provide(Layer.merge(layerKnownWorkspacesTest, NodePath.layerWin32)), + ); + // Rejects without touching the filesystem, so a Windows runner never looks the share up. + const realpath = vi + .mocked(NodeFSP.realpath) + .mockClear() + .mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })); + + yield* workspaceFileSystem + .readFile({ + cwd: "\\\\?\\UNC\\nas.test\\share\\project", + relativePath: "\\\\?\\UNC\\attacker.test\\s\\..\\..\\nas.test\\share\\project\\a.md", + }) + .pipe(Effect.exit); + + expect(realpath.mock.calls.map(([target]) => target)).toEqual([ + "\\\\?\\UNC\\nas.test\\share\\project\\a.md", + ]); + }), + ); + + // Resolving `..` before realpath would read beside the link instead of its target. + // Windows collapses `..` before the filesystem sees a path, so it has no such reads. + it.effect.skipIf(!symlinksSupported || HostProcess.Platform.defaultValue() === "win32")( + "reads `..` after a link from the link's target", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir; + yield* writeTextFile(root, "target/parent.md", "beside the target\n"); + yield* writeTextFile(root, "parent.md", "beside the link\n"); + yield* fs.makeDirectory(path.join(root, "target", "child")); + yield* fs.symlink(path.join(root, "target", "child"), path.join(root, "link")); + + const result = yield* workspaceFileSystem.readFile({ + cwd: root, + relativePath: `${root}/link/../parent.md`, + }); + + expect(result.contents).toBe("beside the target\n"); + }), + ); + it.effect.skipIf(!symlinksSupported)( "rejects symlinks that resolve outside the workspace root", () => diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index ec18f675aae03..4e7c81404256b 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -13,6 +13,7 @@ import type { } from "@t3tools/contracts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isNetworkPath } from "@t3tools/shared/path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -142,7 +143,9 @@ export const make = Effect.gen(function* () { const metadataForPath = Effect.fnUntraced(function* (requestedPath: string) { const expandedPath = expandHomePath(requestedPath, yield* HostProcess.HomeDirectory); - if (!path.isAbsolute(expandedPath)) return null; + // Chips ask as soon as an agent's message renders, and touching a network + // path makes Windows sign in to its host with the user's NTLM credentials. + if (!path.isAbsolute(expandedPath) || isNetworkPath(expandedPath)) return null; const stat = yield* fileSystem.stat(expandedPath).pipe(Effect.option); if (stat._tag === "None") return null; if (stat.value.type === "Directory") return { kind: "directory" } as const; @@ -222,7 +225,8 @@ export const make = Effect.gen(function* () { /** * Workspace-relative paths must stay inside the root, symlinks included. An * absolute or `~/` path reads a host file in place, such as a report an agent - * wrote to a temp directory; it gets no root check. + * wrote to a temp directory; it gets no root check. A network path does, since + * Windows signs in to its host with the user's NTLM credentials. */ const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* ( input: ProjectReadFileInput, @@ -231,15 +235,28 @@ export const make = Effect.gen(function* () { const hostPath = requestedPath.startsWith("~/") ? expandHomePath(requestedPath, yield* HostProcess.HomeDirectory) : requestedPath; + if ( + isNetworkPath(hostPath) && + !KnownWorkspaces.isWithinRoot(path, path.resolve(hostPath), path.resolve(input.cwd)) + ) { + return yield* new WorkspacePaths.WorkspacePathOutsideRootError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + } if (path.isAbsolute(hostPath)) { + // Native realpath hands Windows a `\\?\` path as written, `..` included, + // so a network path must be the resolved one the check above approved. + // Others keep POSIX `link/..` semantics, which resolving first would change. + const absolutePath = isNetworkPath(hostPath) ? path.resolve(hostPath) : hostPath; const realTargetPath = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(hostPath), + try: () => NodeFSP.realpath(absolutePath), catch: (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, - resolvedPath: hostPath, - operationPath: hostPath, + resolvedPath: absolutePath, + operationPath: absolutePath, operation: "realpath-target", cause, }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 77cfc9bea46fc..7d3eb595176b2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -294,6 +294,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import { isNetworkPath } from "@t3tools/shared/path"; import * as RelayClient from "@t3tools/shared/relayClient"; import { isTelemetryIdHash } from "@t3tools/shared/telemetryId"; import { @@ -3349,7 +3350,10 @@ export const layerWsRpc = ( input.resource._tag === "native-app-icon" || input.resource._tag === "tool-output-image" || input.resource._tag === "github-media" || - (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) + // A network path is served only from the thread's project, looked up below. + (input.resource._tag === "media-file" && + path.isAbsolute(input.resource.path) && + !isNetworkPath(input.resource.path)) ) { return yield* issueAssetUrl({ resource: input.resource }); } diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 5c6176b28c06d..92ad8e9083c3f 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import rootPackageJson from "../../package.json" with { type: "json" }; import packageJson from "./package.json" with { type: "json" }; import { WeightedShardSequencer } from "./src/testUtils/weightedShardSequencer.ts"; @@ -34,7 +35,6 @@ if (!TELEMETRY_CHANNELS.includes(telemetryChannel)) { // executable needs a host Node that supports `--build-sea` (25.7+), so this is // a separate mode rather than a second entry in the default build. const packExecutable = process.env.T3CODE_PACK_EXE === "1"; -const SEA_NODE_VERSION = "26.8.2"; const SEA_TARGETS = { "darwin-arm64": { platform: "darwin", arch: "arm64" }, "darwin-x64": { platform: "darwin", arch: "x64" }, @@ -44,6 +44,12 @@ const SEA_TARGETS = { "win-x64": { platform: "win", arch: "x64" }, } as const; const packExecutableTarget = process.env.T3CODE_PACK_EXE_TARGET?.trim(); +const cliNodeVersion = rootPackageJson.engines.node; +if (packExecutable && !packExecutableTarget && process.versions.node !== cliNodeVersion) { + throw new Error( + `Native CLI executables must be built with Node ${cliNodeVersion}; running ${process.versions.node}.`, + ); +} if (packExecutableTarget && !Object.hasOwn(SEA_TARGETS, packExecutableTarget)) { throw new Error( `T3CODE_PACK_EXE_TARGET must be one of ${Object.keys(SEA_TARGETS).join(", ")}, got "${packExecutableTarget}".`, @@ -53,13 +59,17 @@ const packExecutableTargets = packExecutableTarget ? [ { ...SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS], - nodeVersion: SEA_NODE_VERSION, + nodeVersion: cliNodeVersion, }, ] : undefined; +// The root config's `projects` route repo-root runs to package configs, +// including this one. Inheriting them would make this config a container. +const { projects: _rootTestProjects, ...baseTestConfig } = baseConfig.test ?? {}; + export default mergeConfig( - baseConfig, + { ...baseConfig, test: baseTestConfig }, defineConfig({ run: { tasks: { diff --git a/apps/web/package.json b/apps/web/package.json index 244eb50241963..fa1398c82b6bf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,9 +6,9 @@ "scripts": { "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh", "dev": "vp dev", + "dev:built": "vp build --watch", "build": "vp build", "preview": "vp preview", - "typecheck": "tsc-rs --noEmit", "test": "vp test run --passWithNoTests --project unit --project browser" }, "dependencies": { @@ -103,7 +103,7 @@ "@types/use-sync-external-store": "^0.0.6", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.1.2", - "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-playwright": "5.0.5", "compression": "^1.8.2", "oxc-transform-react": "~0.152.0", "playwright": "1.60.0", diff --git a/apps/web/scripts/warm-dep-cache.ts b/apps/web/scripts/warm-dep-cache.ts index 004eb9b53eebb..1b3bbee034642 100644 --- a/apps/web/scripts/warm-dep-cache.ts +++ b/apps/web/scripts/warm-dep-cache.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off - setup-script bootstrap, runs before any Effect runtime exists. +// @effect-diagnostics nodeBuiltinImport:off - plain Node script that dev-runner spawns on a cold cache. /** * The cache cannot be shared between worktrees: Vite's config hash includes * the absolute project root, so each worktree must warm its own. diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 7d1748f9d0c41..1d917de35104c 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -82,7 +82,10 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const [targetId, setTargetId] = useState(request.targetEnvironmentId); const target = targets.find((t) => t.environmentId === targetId) ?? targets[0] ?? null; const source = environments.find((env) => env.environmentId === request.projectRef.environmentId); - const sourceUrl = project?.repositoryIdentity?.locator.remoteUrl ?? ""; + const sourceUrl = + project?.repositoryIdentity?.origin?.remoteUrl ?? + project?.repositoryIdentity?.locator.remoteUrl ?? + ""; const [protocol, setProtocol] = useState(() => cloneUrlProtocol(sourceUrl)); const sshUrl = cloneUrlForProtocol(sourceUrl, "ssh"); const httpsUrl = cloneUrlForProtocol(sourceUrl, "https"); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 189b9abc97440..e1a157dab5c00 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { useEffect, + useRef, useState, useSyncExternalStore, type CSSProperties, @@ -9,7 +10,13 @@ import { } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; +import { isCommandPaletteOpen } from "../commandPaletteBus"; import { isElectron } from "../env"; +import { + releaseThreadUndoShortcut, + threadUndoClaimsShortcut, + undoLatestThreadAction, +} from "../hooks/showThreadUndoNotice"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; import { isRichTextBoldShortcut, @@ -212,7 +219,21 @@ function SidebarControl() { ); } -function NavigationHistoryShortcuts() { +function routePanelsOpen(routeThreadRef: ReturnType) { + if (!routeThreadRef) return { terminalOpen: false, previewOpen: false }; + return { + terminalOpen: selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen, + previewOpen: + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === + "preview", + }; +} + +export function NavigationHistoryShortcuts() { + const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const routeThreadRef = useThreadRouteRef(); @@ -228,19 +249,11 @@ function NavigationHistoryShortcuts() { const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalUiState( - useTerminalUiStateStore.getState().terminalUiStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, previewFocus: isPreviewFocused(), - previewOpen: routeThreadRef - ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === - "preview" - : false, + ...routePanelsOpen(routeThreadRef), editableFocus: isEditableFocused(event.target), modelPickerOpen: isModelPickerOpen(), + usagePageOpen, }, }); if (command !== "navigation.back" && command !== "navigation.forward") return; @@ -253,7 +266,68 @@ function NavigationHistoryShortcuts() { window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, routeThreadRef]); + }, [keybindings, routeThreadRef, usagePageOpen]); + + return null; +} + +const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); + +// Undoes the thread actions in the sidebar's notice. Mounted wherever the +// thread sidebar can show; Settings replaces it. Listens in the capture phase +// so mod+z reaches it before the composer's own undo handler consumes the key. +function ThreadUndoShortcut() { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const routeThreadRef = useThreadRouteRef(); + // A collapsed sidebar hides the notice, so text fields keep their own undo. + const noticeVisible = useSidebarVisibility(); + // Outlives the effect: undoing an archive navigates, which re-runs it mid-hold. + const heldUndoKey = useRef(null); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + // A held key undoes once. Cancel its repeats so they cannot fall through + // to the editor's own undo, even after the last notice is gone. + if (event.repeat) { + if (event.code === heldUndoKey.current) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + // A modifier tapped mid-hold leaves the held key repeating. + if (!MODIFIER_KEYS.has(event.key)) heldUndoKey.current = null; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + previewFocus: isPreviewFocused(), + ...routePanelsOpen(routeThreadRef), + editableFocus: + isEditableFocused(event.target) && !(noticeVisible && threadUndoClaimsShortcut()), + modelPickerOpen: isModelPickerOpen(), + }, + }); + if (command !== "thread.undo") { + if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut(); + return; + } + if (isCommandPaletteOpen() || isModelPickerOpen()) return; + if (undoLatestThreadAction()) { + heldUndoKey.current = event.code; + event.preventDefault(); + event.stopPropagation(); + } + }; + // Pastes from a menu, dictation and drops edit text without a keypress. + const onInput = () => releaseThreadUndoShortcut(); + + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("input", onInput, true); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("input", onInput, true); + }; + }, [keybindings, noticeVisible, routeThreadRef]); return null; } @@ -384,6 +458,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} + {isOnSettings ? null : } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 247171065bfcf..fbfd4c361845b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,11 +1,4 @@ -import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; -import { MarkdownFindContext, findRevealRef } from "./chat/markdownFindContext"; -import { - buildFileLinkParentSuffixByPath, - fileLinkLabel, - resolvePathLinkTarget, - splitFilePathPosition, -} from "@t3tools/shared/fileLinks"; +import { buildFileLinkParentSuffixByPath, fileLinkLabel } from "@t3tools/shared/fileLinks"; import { isWindowsDrivePathHref, normalizeMarkdownLinkDestination, @@ -27,8 +20,6 @@ import { encodeComposerContextClipboardHtml, } from "@t3tools/shared/composerContextClipboard"; import { - ChevronRightIcon, - CodeIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, @@ -39,19 +30,14 @@ import { MessageSquareIcon, MessageSquareWarningIcon, OctagonAlertIcon, - PlayIcon, PresentationIcon, SparklesIcon, TriangleAlertIcon, - WorkflowIcon, - WrapTextIcon, type LucideIcon, } from "lucide-react"; -import { Check, Copy, Maximize2, Minimize2 } from "lucide"; import { AssetWorkspaceAssetNotFoundError, AuthPreviewOperateScope, - type AssetResource, type EditorId, type EnvironmentId, type ServerConfig, @@ -59,12 +45,10 @@ import { type ServerProviderSkill, type ThreadPullRequestKey, } from "@t3tools/contracts"; -import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; import { isAtomCommandInterrupted, squashAtomCommandFailure, - type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { codexArtifactTemplatePresentationLabel, @@ -75,25 +59,18 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; -import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; -import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; +import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; import React, { - Children, Suspense, - type CSSProperties, type ComponentProps, type ClipboardEvent as ReactClipboardEvent, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, - isValidElement, use, memo, - useEffect, useLayoutEffect, useRef, useState, @@ -105,7 +82,6 @@ import type { Options as ReactMarkdownOptions, } from "react-markdown"; import ReactMarkdown from "react-markdown"; -import { toHtml } from "hast-util-to-html"; import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; @@ -126,13 +102,9 @@ import { type ExpandedImagePreview, } from "./chat/ExpandedImagePreview"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; -import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; -import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; -import { MediaActions, type MediaActionSource } from "./media/MediaActions"; +import { markdownImageGallery } from "./chat/markdownImageGallery"; +import { type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; -import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; -import { FileTagChipContent } from "./chat/FileTagChip"; -import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { revealInFileExplorerLabelForKind, revealInFileExplorerLabelForOs, @@ -141,14 +113,8 @@ import { resolveExternalWebLinkHost, showExternalLinkContextMenu, } from "./chat/externalLinkContextMenu"; -import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; -import { MorphIcon } from "~/components/MorphIcon"; -import { ContextChip } from "./ContextChip"; -import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; -import { ScrollArea } from "./ui/scroll-area"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; import { @@ -157,28 +123,15 @@ import { usePreferredEditor, } from "../editorPreferences"; import { openInEditorMenuLabel } from "../editorLabels"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; -import { fnv1a32 } from "../lib/diffRendering"; -import { LRUCache } from "../lib/lruCache"; -import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; -import { GitHubIcon } from "./Icons"; -import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; -import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; +import { resolveDiffThemeName } from "../lib/diffRendering"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; -import { MermaidDiagram } from "./chat/MermaidDiagram"; import { useRenderedAppearance } from "../hooks/useTheme"; -import { getClientSettings, useClientSettings } from "../hooks/useSettings"; -import { - chatMarkdownClipboardPayload, - serializeTableElementToCsv, - serializeTableElementToMarkdown, -} from "../markdown-clipboard"; +import { useClientSettings } from "../hooks/useSettings"; +import { chatMarkdownClipboardPayload } from "../markdown-clipboard"; import { resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, - shouldOpenMarkdownFileLinkInBrowserByDefault, - shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; import { @@ -189,13 +142,8 @@ import { import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; -import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; -import { - resolveRemoteOpenState, - useRemoteOpenResolution, - type RemoteOpenMode, -} from "../remoteOpen"; +import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; @@ -205,7 +153,6 @@ import { readEnvironmentScope, readPreparedConnection, useEnvironmentScope, - usePreparedConnection, } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; @@ -226,7 +173,6 @@ import { resolvePullRequestPreviewTarget, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; -import { observeResize } from "~/lib/observeResize"; import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewAvailableFor } from "../browser/previewRuntime"; @@ -241,6 +187,34 @@ import { resolveLinkTarget } from "../browser/browserLinkTarget"; // T3-CUSTOM(expbkt3): every link opens in the integrated browser. import { effectiveBrowserLinkTarget } from "../fork/effectiveBrowserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; +import { MarkdownCodeBlock, SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBlock"; +import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock"; +import { MarkdownTable } from "./chat/MarkdownTable"; +import { MarkdownDetails } from "./chat/MarkdownDetails"; +import { + authoredImageSizeStyle, + ChatMarkdownAssetImage, + ChatMarkdownImage, + ChatMarkdownImageFallback, + ChatMarkdownVideo, + MarkdownLinkContext, + markdownImageCopy, +} from "./chat/ChatMarkdownMedia"; +import { MarkdownExternalLinkContent } from "./chat/MarkdownExternalLink"; +import { MarkdownFileLink } from "./chat/MarkdownFileLink"; +import { canUseMarkdownFileShellActions } from "./chat/MarkdownFileLink.logic"; +import { handleMarkdownFragmentClick, rehypeHeadingIds } from "./chat/markdownFragments"; +import { rehypeTextDirection } from "./chat/markdownTextDirection"; +import { hastHasText, hastPlainTextDeep, plainHastText } from "./chat/markdownHast.logic"; +import { + extractCodeBlock, + extractFenceLanguage, + extractFenceTitle, + extractPreCodeMeta, + isClosedCodeFence, + nodeToPlainText, +} from "./chat/MarkdownCodeBlock.logic"; +import { reportMarkdownActionFailure } from "./chat/markdownActionFailure"; const isAssetNotFoundError = Schema.is(AssetWorkspaceAssetNotFoundError); @@ -277,60 +251,6 @@ export interface ChatMarkdownContextReference { label: string; } -export function canUseMarkdownFileShellActions( - environmentId: EnvironmentId | null, - remoteOpenMode: RemoteOpenMode, - isRemoteOpenResolved: boolean, -): boolean { - return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; -} - -function hasMarkdownFilePrimaryAction(input: { - canOpenInEditor: boolean; - canOpenInBrowser: boolean; - canOpenInPanel: boolean; - canOpenMedia?: boolean; -}): boolean { - return ( - input.canOpenInEditor || - input.canOpenInBrowser || - input.canOpenInPanel || - input.canOpenMedia === true - ); -} - -export function markdownFileCopyMenuItems({ - targetPath, - workspaceRelativePath, -}: Pick) { - const items = [ - { id: "copy-full", label: "Copy full path", value: targetPath, title: "Full path" }, - ]; - if (workspaceRelativePath !== null) { - const position = targetPath.slice(splitFilePathPosition(targetPath).path.length); - items.unshift({ - id: "copy-relative", - label: "Copy relative path", - value: `${workspaceRelativePath}${position}`, - title: "Relative path", - }); - } - return items; -} - -export function shouldUseMarkdownFileBrowserPrimaryAction(input: { - iconPath: string; - canOpenInEditor: boolean; - canOpenInBrowser: boolean; - canOpenInPanel: boolean; -}): boolean { - return ( - input.canOpenInBrowser && - (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || - (!input.canOpenInEditor && !input.canOpenInPanel)) - ); -} - const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; @@ -394,28 +314,7 @@ function CodexArtifactTemplateCard(props: { ); } -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; - -interface MarkdownActionFailureContext { - readonly operation: string; - readonly target?: string; - readonly format?: "markdown" | "csv"; - readonly language?: string; - readonly fenceTitle?: string; - readonly copyTarget?: string; -} - -function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void { - console.error("[chat-markdown] action failed", context, cause); -} - -const highlightedCodeCache = new LRUCache( - MAX_HIGHLIGHT_CACHE_ENTRIES, - MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, -); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); @@ -476,779 +375,6 @@ const GITHUB_ALERT_PRESENTATIONS: Record< }, }; -function extractFenceLanguage(className: string | undefined): string { - const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); - const raw = match?.[1] ?? "text"; - // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685) - return raw === "gitignore" ? "ini" : raw; -} - -const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i; -const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/; - -function extractFenceTitle(meta: string | undefined): string | null { - if (!meta) return null; - const attrMatch = FENCE_TITLE_ATTR_REGEX.exec(meta); - const attrTitle = attrMatch?.[1] ?? attrMatch?.[2] ?? attrMatch?.[3]; - if (attrTitle) return attrTitle; - return meta.split(/\s+/).find((candidate) => FENCE_FILENAME_TOKEN_REGEX.test(candidate)) ?? null; -} - -function extractPreCodeMeta(node: unknown): string | undefined { - const children = ( - node as - | { - children?: Array<{ - type?: string; - tagName?: string; - data?: { meta?: unknown }; - properties?: { dataCodeMeta?: unknown }; - }>; - } - | undefined - )?.children; - const codeNode = children?.find((child) => child?.type === "element" && child.tagName === "code"); - const meta = codeNode?.properties?.dataCodeMeta ?? codeNode?.data?.meta; - return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; -} - -function isClosedCodeFence( - node: ReactMarkdownExtraProps["node"], - text: string, - code: string, -): boolean { - const start = node?.position?.start.offset; - const end = node?.position?.end.offset; - if (start === undefined || end === undefined) return false; - const source = text.slice(start, end); - const opening = /^(?:`{3,}|~{3,})/.exec(source)?.[0]; - // One class for the blockquote prefix: nested quantifiers here backtrack - // exponentially on code lines that start with many `> ` markers. - const closing = /(?:^|\n)[ \t>]*(`{3,}|~{3,})[ \t\r]*$/.exec(source)?.[1]; - return ( - opening !== undefined && - closing !== undefined && - opening[0] === closing[0] && - closing.length >= opening.length && - // Only a real closer is omitted from parsed code. A CRLF stream cut after - // CR must not count as an extra source line. - source.replace(/\r$/, "").split(/\r\n|\r|\n/).length > code.split(/\r\n|\r|\n/).length - ); -} - -function nodeToPlainText(node: ReactNode): string { - if (typeof node === "string" || typeof node === "number") { - return String(node); - } - if (Array.isArray(node)) { - return node.map((child) => nodeToPlainText(child)).join(""); - } - if (isValidElement<{ children?: ReactNode }>(node)) { - return nodeToPlainText(node.props.children); - } - return ""; -} - -function extractCodeBlock( - children: ReactNode, -): { className: string | undefined; code: string } | null { - const childNodes = Children.toArray(children); - if (childNodes.length !== 1) { - return null; - } - - const onlyChild = childNodes[0]; - if ( - !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( - onlyChild, - ) - ) { - return null; - } - // With a custom `code` component the child's type is that component, not - // the "code" tag — the hast node react-markdown attaches still names it. - if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") { - return null; - } - - return { - className: onlyChild.props.className, - code: nodeToPlainText(onlyChild.props.children), - }; -} - -function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { - return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; -} - -function estimateHighlightedSize(html: string, code: string): number { - return Math.max(html.length * 2, code.length * 3); -} - -function readInitialWordWrapSetting(): boolean { - return getClientSettings().wordWrap; -} - -/** - * Whether any cell is clipped in the unwrapped layout. Only then does the wrap - * toggle change anything, so short tables hide it. Measures with the container - * briefly unwrapped and restores it before the browser paints. - */ -function tableHasClippedCells(container: HTMLElement, table: HTMLTableElement): boolean { - const viewport = table.closest('[data-slot="scroll-area-viewport"]'); - const scrollLeft = viewport?.scrollLeft ?? 0; - const previous = container.dataset.expanded; - container.dataset.expanded = "false"; - try { - for (const row of table.rows) { - for (const cell of row.cells) { - if (cell.scrollWidth > cell.clientWidth) return true; - } - } - return false; - } finally { - if (previous === undefined) delete container.dataset.expanded; - else container.dataset.expanded = previous; - // The unwrapped layout can be narrower and clamp a sideways scroll. - if (viewport && scrollLeft > 0) viewport.scrollLeft = scrollLeft; - } -} - -function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { - const containerRef = useRef(null); - const tableRef = useRef(null); - const [expanded, setExpanded] = useState(readInitialWordWrapSetting); - const [hasClippedCells, setHasClippedCells] = useState(false); - const [copied, setCopied] = useState(false); - const copiedTimerRef = useRef | null>(null); - const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; - const copyLabel = copied ? "Copied" : "Copy table"; - - const measureClippedCells = () => { - const container = containerRef.current; - const table = tableRef.current; - if (!container || !table) return; - setHasClippedCells(tableHasClippedCells(container, table)); - }; - - // A streamed cell can grow until it clips while the table keeps its size, - // so remeasure after every render, not only on resize. - useLayoutEffect(measureClippedCells); - - // Column width changes can clip a cell without rendering this table. - useLayoutEffect(() => { - const table = tableRef.current; - if (!table) return; - return observeResize(table, measureClippedCells); - }, [measureClippedCells]); - - const handleCopy = (format: "markdown" | "csv") => { - const table = containerRef.current?.querySelector("table"); - if (!table || typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } - const text = - format === "markdown" - ? serializeTableElementToMarkdown(table) - : serializeTableElementToCsv(table); - void navigator.clipboard - .writeText(text) - .then(() => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1200); - }) - .catch((cause) => { - reportMarkdownActionFailure({ operation: "copy-table", format }, cause); - }); - }; - - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - copiedTimerRef.current = null; - } - }, - [], - ); - - return ( -

- - - {children} -
-
-
- {hasClippedCells ? ( - - setExpanded((value) => !value)} - aria-label={expandLabel} - /> - } - > - - - {expandLabel} - - ) : ( - - )} - - - - } - /> - } - > - - - {copyLabel} - - - handleCopy("markdown")}>Copy as Markdown - handleCopy("csv")}>Copy as CSV - - -
-
- ); -} - -function MarkdownDetails({ - children, - open = false, -}: Pick, "children" | "open">) { - const [isOpen, setIsOpen] = useState(open); - const searching = use(MarkdownFindContext); - const expanded = isOpen; - const revealForFind = () => setIsOpen(true); - // Base UI only listens for `beforematch` on a panel mounted at its first - // render; a closed panel mounts later, when find starts, so listen here. - const revealRef = findRevealRef(revealForFind); - const childNodes = Children.toArray(children); - const summaryIndex = childNodes.findIndex( - (child) => isValidElement(child) && child.type === "summary", - ); - const summaryNode = summaryIndex >= 0 ? childNodes[summaryIndex] : null; - const summary = - isValidElement<{ children?: ReactNode }>(summaryNode) && summaryNode.props.children - ? summaryNode.props.children - : "Details"; - const content = childNodes.filter((_, index) => index !== summaryIndex); - - return ( -
- - - - {summary} - - -
- {content} -
-
-
-
- ); -} - -const THREAD_FIND_IGNORE_PROPS = { - "data-thread-find-ignore": "", -} as React.HTMLAttributes; - -export function MarkdownCodeBlockFrame({ - as: Wrapper = "div", - language, - fenceTitle, - theme, - wrapped = true, - title, - actions, - headerProps, - children, -}: { - as?: React.ElementType; - language: string; - fenceTitle: string | null; - theme: "light" | "dark"; - wrapped?: boolean; - title?: React.ReactNode; - actions?: React.ReactNode; - headerProps?: React.HTMLAttributes; - children: React.ReactNode; -}) { - return ( - -
- {title ?? ( - - - - )} - {actions} -
- {children} -
- ); -} - -function MarkdownCodeBlockTitleContent({ - fenceTitle, - language, - theme, -}: { - fenceTitle: string | null; - language: string; - theme: "light" | "dark"; -}) { - if (fenceTitle) { - return ( - <> - - {fenceTitle} - - ); - } - - const fileName = syntheticFileNameForLanguageId(language); - if (!hasSpecificPierreIconForFileName(fileName)) { - return {language}; - } - return ( - - - } - > - - - {language} - - ); -} - -function MarkdownCodeBlock({ - code, - language, - fenceTitle, - theme, - onRunShellCommand, - isStreaming, - viewToggle, - canWrap = true, - diagram = false, - children, -}: { - code: string; - language: string; - fenceTitle: string | null; - theme: "light" | "dark"; - onRunShellCommand?: ((command: string) => void) | undefined; - isStreaming: boolean; - viewToggle?: ReactNode; - canWrap?: boolean; - diagram?: boolean; - children: ReactNode; -}) { - const [copied, setCopied] = useState(false); - const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); - const copiedTimerRef = useRef | null>(null); - const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; - const copyLabel = copied ? "Copied" : "Copy code"; - const command = code.trim(); - const canRun = - onRunShellCommand !== undefined && - !isStreaming && - /^(?:sh|bash|zsh|fish|shell|powershell|pwsh)$/.test(language) && - code.endsWith("\n") && - command.length > 0 && - !command.endsWith("\\") && - // Control and invisible format characters (bidi overrides, zero-width) can - // make the rendered command differ from what the terminal would receive. - !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1)); - - const handleCopy = () => { - if (typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } - void navigator.clipboard - .writeText(code) - .then(() => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1200); - }) - .catch((cause) => { - reportMarkdownActionFailure( - { - operation: "copy-code-block", - language, - ...(fenceTitle ? { fenceTitle } : {}), - }, - cause, - ); - }); - }; - - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - copiedTimerRef.current = null; - } - }, - [], - ); - - const copyButton = ( - - - } - > - - - {copyLabel} - - ); - - if (diagram) { - return ( -
-
- {viewToggle} - {copyButton} -
- {children} -
- ); - } - - return ( - - {canWrap ? ( - - setWrapped((value) => !value)} - aria-label={wrapLabel} - /> - } - > - - - {wrapLabel} - - ) : null} - {canRun ? ( - - onRunShellCommand(command)} - aria-label="Run in terminal" - /> - } - > - - - Run in terminal - - ) : null} - {viewToggle} - {copyButton} - - } - > - {children} - - ); -} - -function MarkdownMermaidCodeBlock({ - code, - fenceTitle, - theme, - isStreaming, - onExpand, - children, -}: { - code: string; - fenceTitle: string | null; - theme: "light" | "dark"; - isStreaming: boolean; - onExpand: (imageUrl: string) => void; - children: ReactNode; -}) { - const [showCode, setShowCode] = useState(false); - const searching = use(MarkdownFindContext); - const revealSource = () => setShowCode(true); - const sourceRevealRef = findRevealRef(revealSource); - const showDiagram = !showCode && !isStreaming && code.trim().length > 0; - const toggleLabel = showCode ? "Show diagram" : "Show code"; - return ( - - setShowCode((value) => !value)} - aria-label={toggleLabel} - /> - } - > - {showCode ? : } - - {toggleLabel} - - ) - } - > - {showDiagram ? ( - - - {code} - - } - > - - - {searching ? ( - - ) : null} - - ) : ( - children - )} - - ); -} - -interface SuspenseShikiCodeBlockProps { - className: string | undefined; - code: string; - themeName: DiffThemeName; - isStreaming: boolean; -} - -function SuspenseShikiCodeBlock({ - className, - code, - themeName, - isStreaming, -}: SuspenseShikiCodeBlockProps) { - const [hasStreamed, setHasStreamed] = useState(isStreaming); - if (isStreaming && !hasStreamed) setHasStreamed(true); - const language = extractFenceLanguage(className); - const cacheKey = createHighlightCacheKey(code, language, themeName); - // Once lines are mounted individually, keep that renderer when streaming - // finishes so switching to cached HTML cannot clear an existing selection. - const cachedHighlightedHtml = - !isStreaming && !hasStreamed ? highlightedCodeCache.get(cacheKey) : null; - - if (cachedHighlightedHtml != null) { - return ( -
- ); - } - - return ( - - ); -} - -interface UncachedShikiCodeBlockProps { - code: string; - language: string; - themeName: DiffThemeName; - cacheKey: string; - isStreaming: boolean; - preserveLines: boolean; -} - -function UncachedShikiCodeBlock({ - code, - language, - themeName, - cacheKey, - isStreaming, - preserveLines, -}: UncachedShikiCodeBlockProps) { - const highlighter = use(getSyntaxHighlighterPromise(language)); - const incrementalHighlight = preserveLines - ? createIncrementalHighlightedDocument(highlighter, language, themeName) - : null; - const highlighted = (() => { - try { - if (incrementalHighlight) return incrementalHighlight(code); - return preserveLines - ? highlighter.codeToHast(code, { lang: language, theme: themeName }) - : highlighter.codeToHtml(code, { lang: language, theme: themeName }); - } catch (error) { - console.warn( - `Code highlighting failed for language "${language}", falling back to plain text.`, - error instanceof Error ? error.message : error, - ); - return preserveLines - ? highlighter.codeToHast(code, { lang: "text", theme: themeName }) - : highlighter.codeToHtml(code, { lang: "text", theme: themeName }); - } - })(); - - useEffect(() => { - if (!isStreaming) { - const highlightedHtml = typeof highlighted === "string" ? highlighted : toHtml(highlighted); - highlightedCodeCache.set( - cacheKey, - highlightedHtml, - estimateHighlightedSize(highlightedHtml, code), - ); - } - }, [cacheKey, code, highlighted, isStreaming]); - - return typeof highlighted === "string" ? ( -
- ) : ( -
- -
- ); -} - -interface MarkdownFileLinkProps { - environmentId: EnvironmentId | null; - href: string; - targetPath: string; - iconPath: string; - workspaceRelativePath: string | null; - panelPath: string | null; - line?: number | undefined; - label: string; - copyMarkdown: string; - theme: "light" | "dark"; - threadRef?: ScopedThreadRef | undefined; - onOpen?: (() => Promise>) | undefined; - onOpenInPanel: (panelPath: string, line: number | undefined) => void; - resolveCopyPaths: () => Promise>; - openInEditorMenuLabel: string; - onOpenInBrowser?: (() => Promise>) | undefined; - onOpenMedia?: (() => void) | undefined; - onReveal?: (() => Promise>) | undefined; - revealLabel?: string | undefined; -} - -const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; - function normalizeMarkdownLinkHrefKey(href: string): string { const normalizedHref = normalizeMarkdownLinkDestination(href); const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; @@ -1257,1074 +383,17 @@ function normalizeMarkdownLinkHrefKey(href: string): string { : rewrittenHref; } -const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; - -const failedFaviconHosts = new Set(); - -function isGitHubHost(host: string): boolean { - const hostname = host.toLowerCase(); - return hostname === "github.com" || hostname.endsWith(".github.com"); -} - -const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { - const [failedHost, setFailedHost] = useState(null); - const brandIcon = isGitHubHost(host); - const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); - return ( - - {brandIcon ? ( - - ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( - - ) : ( - { - failedFaviconHosts.add(host); - setFailedHost(host); - }} - /> - )} - - ); -}); - -const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; -const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( - "max-h-[30rem]", - CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, -); -const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; -const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; -const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( - "h-auto w-auto object-contain", - CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, -); - -function markdownImageCopy(alt: string, src: string, title: string | undefined): string { - const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); - const titleSuffix = - title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; - return `![${escapedAlt}](${src}${titleSuffix})`; -} - -/** - * `maxHeightRem` folds a height cap into the width bound: `max-height` alone - * would not feed back through `aspect-ratio` once `width` is definite, so a - * tall image would keep a box wider than the picture it draws. - */ -function authoredImageSizeStyle( - width: string | number | undefined, - height: string | number | undefined, - maxHeightRem = 30, -): CSSProperties | undefined { - const parsedWidth = Number(width); - const parsedHeight = Number(height); - const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0; - const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0; - if (hasWidth && hasHeight) { - return { - width: parsedWidth, - height: "auto", - aspectRatio: `${parsedWidth} / ${parsedHeight}`, - maxWidth: `min(100%, 30rem, ${(maxHeightRem * parsedWidth) / parsedHeight}rem)`, - }; - } - if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` }; - if (hasHeight) return { maxHeight: `min(30rem, ${parsedHeight}px)` }; - return undefined; -} - -const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( - CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, - CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, -); -const MarkdownLinkContext = React.createContext(false); - -function expandableMarkdownImageProps( - onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, - alt: string, -) { - if (!onImageExpand) return {}; - const previewName = alt.trim() || "image"; - const expand = (event: ReactMouseEvent | ReactKeyboardEvent) => { - if (event.currentTarget.closest("a")) return; - event.preventDefault(); - event.stopPropagation(); - const item = markdownImageItems.get(event.currentTarget); - if (item) onImageExpand(markdownImageGallery(event.currentTarget, item)); - }; - return { - role: "button" as const, - tabIndex: 0, - "aria-label": `Preview ${previewName}`, - onClick: expand, - onKeyDown: (event: ReactKeyboardEvent) => { - if (event.key === "Enter" || event.key === " ") expand(event); - }, - }; -} - -function ChatMarkdownMediaUnavailableLabel(props: { - readonly alt: string; - readonly kind?: "image" | "video" | undefined; - readonly detail?: string | undefined; -}) { - const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; - return ( - - - {[label, props.alt, props.detail].filter(Boolean).join(" · ")} - - ); -} - -function ChatMarkdownImageFallback(props: { - readonly alt: string; - readonly copyMarkdown?: string | undefined; - readonly kind?: "image" | "video"; - readonly actionsSource?: MediaActionSource | undefined; - readonly detail?: string | undefined; -}) { - const content = ( - - - - ); - return props.actionsSource ? ( - {content} - ) : ( - content - ); -} - -const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( - "aspect-video w-full overflow-hidden bg-muted/60", - CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, - CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, -); - -/** - * Callers key this on the file's identity, not its URL: a re-signed URL for - * the same file keeps the decoded image on screen while the new bytes arrive, - * and a different file starts from the slot again. - */ -function ChatMarkdownImage(props: { - readonly src: string | null; - readonly sourceFailed?: boolean | undefined; - readonly unavailableDetail?: string | undefined; - readonly alt: string; - readonly copyMarkdown: string | undefined; - readonly standalone: boolean; - readonly className?: string | undefined; - readonly style?: CSSProperties | undefined; - readonly imageProps?: - | Omit, "src" | "alt" | "className" | "style"> - | undefined; - readonly actionsSource: MediaActionSource; - readonly originalUrl?: string | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; -}) { - const [loadedSrc, setLoadedSrc] = useState(null); - const [failedSrc, setFailedSrc] = useState(null); - const src = props.src ?? loadedSrc; - const failed = props.sourceFailed === true || (src !== null && failedSrc === src); - const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); - // Cached images are complete before `onLoad` can fire. - const markLoadedIfComplete = (image: HTMLImageElement | null) => { - if (!image) return; - if (image.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); - markdownImageItems.set(image, { - src, - name: props.alt.trim() || "image", - actionsSource: props.actionsSource, - ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), - }); - }; - const imageEvents = (loadingSrc: string) => ({ - onLoad: () => { - setLoadedSrc(loadingSrc); - setFailedSrc(null); - }, - onError: () => { - setFailedSrc(loadingSrc); - setLoadedSrc(null); - }, - }); - - if (settled) { - return ( - - {props.alt} - - ); - } - if (!props.standalone) { - return failed ? ( - - ) : ( - - ); - } - return ( - - - {failed ? ( - - - - ) : src !== null ? ( - {props.alt} - ) : null} - - - ); -} - -function ChatMarkdownVideo(props: { - readonly src: string | null; - readonly alt: string; - readonly copyMarkdown: string | undefined; - readonly originalUrl?: string | undefined; - readonly sourceFailed?: boolean | undefined; - readonly style?: CSSProperties | undefined; - readonly mediaIdentity?: string | undefined; - readonly actionsSource?: MediaActionSource | undefined; - readonly onRetry?: (() => Promise) | undefined; -}) { - return ( - - ); -} - -export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { - readonly environmentId: EnvironmentId; - readonly resource: Extract< - AssetResource, - { - readonly _tag: - | "attachment" - | "workspace-file" - | "media-file" - | "github-media" - | "tool-output-image"; - } - >; - readonly kind?: "image" | "video"; - readonly alt: string; - readonly copyMarkdown?: string; - readonly srcFragment?: string; - readonly standalone?: boolean | undefined; - readonly maxHeightRem?: number | undefined; - readonly style?: CSSProperties | undefined; - readonly className?: string | undefined; - readonly imageProps?: - | Omit, "src" | "alt" | "className" | "style"> - | undefined; - readonly originalUrl?: string | undefined; - readonly framed?: boolean | undefined; - readonly fallbackSrc?: string | undefined; - readonly workspaceRoot?: string | undefined; - readonly workspaceMutationId?: string | null | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; -}) { - const assetUrl = useAssetUrlState(props.environmentId, props.resource); - const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); - const connectionReady = usePreparedConnection(props.environmentId)._tag === "Some"; - const resource = props.resource; - const refreshToken = JSON.stringify([props.environmentId, resource, props.workspaceMutationId]); - const handledRefreshToken = useRef(undefined); - const assetLoading = assetUrl._tag === "Loading" || assetUrl.waiting === true; - useEffect(() => { - if (!connectionReady) { - handledRefreshToken.current = null; - return; - } - if ( - props.workspaceMutationId === undefined || - props.kind === "video" || - (resource._tag !== "media-file" && resource._tag !== "workspace-file") || - handledRefreshToken.current === refreshToken - ) { - return; - } - if (assetLoading) { - // Only the initial mount shares a pending request; edits and reconnects need a new one. - if (handledRefreshToken.current === undefined) { - handledRefreshToken.current = refreshToken; - } - return; - } - handledRefreshToken.current = refreshToken; - void refreshAssetUrl().catch(() => undefined); - }, [ - assetLoading, - connectionReady, - props.kind, - props.workspaceMutationId, - refreshAssetUrl, - refreshToken, - resource._tag, - ]); - const path = - resource._tag === "media-file" - ? resource.path - : resource._tag === "workspace-file" && props.workspaceRoot - ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` - : undefined; - const reference = path - ? mediaFileReference(path, props.workspaceRoot) - : props.originalUrl - ? mediaUrlReference(props.originalUrl) - : undefined; - const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; - const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; - const src = - assetUrl._tag === "Success" - ? assetUrl.url + (props.srcFragment ?? "") - : fallbackSrc === undefined - ? null - : fallbackSrc + (props.srcFragment ?? ""); - const knownSize = assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined; - const maxHeightRem = props.maxHeightRem ?? 30; - const style = - props.style ?? - (knownSize - ? authoredImageSizeStyle(knownSize.width, knownSize.height, maxHeightRem) - : maxHeightRem !== 30 - ? { maxHeight: `${maxHeightRem}rem` } - : undefined); - const actionsSource: MediaActionSource = { - kind: props.kind ?? "image", - name: props.alt || (props.kind ?? "image"), - src, - ...(fallbackSrc === undefined - ? { asset: { environmentId: props.environmentId, resource } } - : {}), - ...(reference ? { reference } : {}), - ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") - ? { - onOpenFile: () => - useRightPanelStore - .getState() - .openFile( - { environmentId: props.environmentId, threadId: resource.threadId }, - relativePath, - ), - } - : {}), - }; - - if (props.kind === "video") { - return ( - - ); - } - - return ( - - ); -}); - -function leadingExternalLinkTextLength(text: string): number { - const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; - if (protocol) return protocol.length; - return Math.min(text.length, 1); -} - -function breakableExternalLinkText(text: string): ReactNode[] { - return Array.from(text, (character, index) => ( - - {character} - - - )); -} - -function plainHastText(node: unknown): string | null { - if (!node || typeof node !== "object" || !("children" in node) || !Array.isArray(node.children)) { - return null; - } - const parts = node.children.map((child) => { - if ( - child && - typeof child === "object" && - "type" in child && - child.type === "text" && - "value" in child && - typeof child.value === "string" - ) { - return child.value; - } - return null; - }); - return parts.every((part) => part !== null) ? parts.join("") : null; -} - -function hastPlainTextDeep(node: unknown): string { - if (!node || typeof node !== "object") return ""; - if ("type" in node && node.type === "text" && "value" in node && typeof node.value === "string") { - return node.value; - } - if (!("children" in node) || !Array.isArray(node.children)) return ""; - return node.children.map(hastPlainTextDeep).join(""); -} - -function hastHasText(node: unknown): boolean { - if (!node || typeof node !== "object") return false; - if ( - "type" in node && - node.type === "text" && - "value" in node && - typeof node.value === "string" && - node.value.trim().length > 0 - ) { - return true; - } - return "children" in node && Array.isArray(node.children) && node.children.some(hastHasText); -} - -const SANITIZED_FRAGMENT_PREFIX = "user-content-"; - -function decodeMarkdownFragmentId(href: string): string { - const encodedId = href.slice(1); - try { - return decodeURIComponent(encodedId); - } catch { - return encodedId; - } -} - -function normalizeSanitizedFragmentId(id: string): string { - let normalizedId = id; - while (normalizedId.startsWith(SANITIZED_FRAGMENT_PREFIX)) { - normalizedId = normalizedId.slice(SANITIZED_FRAGMENT_PREFIX.length); - } - return normalizedId; -} - -function findMarkdownFragmentTarget(anchor: HTMLAnchorElement, href: string): HTMLElement | null { - const decodedId = decodeMarkdownFragmentId(href); - const normalizedId = normalizeSanitizedFragmentId(decodedId); - const matchesFragment = (element: HTMLElement) => - element.id === decodedId || normalizeSanitizedFragmentId(element.id) === normalizedId; - const markdownRoot = anchor.closest(".chat-markdown"); - if (markdownRoot) { - const localTargets = Array.from(markdownRoot.querySelectorAll("[id]")); - const localTarget = localTargets.find(matchesFragment); - if (localTarget) return localTarget; - } - - return ( - document.getElementById(decodedId) ?? - Array.from(document.querySelectorAll("[id]")).find(matchesFragment) ?? - null - ); -} - -function handleMarkdownFragmentClick(event: ReactMouseEvent, href: string) { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } - - // Never let the browser follow the fragment or write it to the URL: desktop keeps - // its route in the hash, so replacing the hash navigates away from the thread. - event.preventDefault(); - findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); -} - -type HeadingHastNode = { - type?: string; - tagName?: string; - properties?: Record; - children?: HeadingHastNode[]; -}; - -function githubHeadingSlug(text: string): string { - return text - .trim() - .toLowerCase() - .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "") - .replace(/ /g, "-"); -} - -/** - * Like the sanitizer's ids, they carry the `user-content-` prefix so they cannot clobber app - * element ids; fragment lookup strips it. - */ -function rehypeHeadingIds() { - return (tree: HeadingHastNode) => { - const taken = new Set(); - const collect = (node: HeadingHastNode) => { - const id = node.properties?.id; - if (typeof id === "string") taken.add(id); - node.children?.forEach(collect); - }; - collect(tree); - const nextSuffix = new Map(); - const visit = (node: HeadingHastNode) => { - if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { - const slug = githubHeadingSlug(hastPlainTextDeep(node)); - if (node.properties?.id === undefined && slug) { - let count = nextSuffix.get(slug) ?? 0; - let id = `${SANITIZED_FRAGMENT_PREFIX}${slug}`; - while (taken.has(id)) { - count += 1; - id = `${SANITIZED_FRAGMENT_PREFIX}${slug}-${count}`; - } - nextSuffix.set(slug, count); - taken.add(id); - node.properties = { ...node.properties, id }; - } - return; - } - node.children?.forEach(visit); - }; - visit(tree); - }; -} - // Heading ids are added after sanitizing, which would prefix them a second time. const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ ...CHAT_MARKDOWN_REHYPE_PLUGINS, rehypeHeadingIds, + rehypeTextDirection, ] satisfies NonNullable; -const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< - ReactMarkdownOptions["rehypePlugins"] ->; - -function MarkdownExternalLinkContent({ - host, - plainText, - children, -}: { - host: string; - plainText: string | null; - children: ReactNode; -}) { - if (plainText) { - const leadingLength = leadingExternalLinkTextLength(plainText); - return ( - <> - - - {plainText.slice(0, leadingLength)} - - {breakableExternalLinkText(plainText.slice(leadingLength))} - - ); - } - - const childNodes = Children.toArray(children); - const firstChild = childNodes[0]; - - if (typeof firstChild === "string" && firstChild.length > 0) { - const leadingLength = leadingExternalLinkTextLength(firstChild); - return ( - <> - - - {firstChild.slice(0, leadingLength)} - - {breakableExternalLinkText(firstChild.slice(leadingLength))} - {childNodes.slice(1)} - - ); - } - - return ( - <> - - - {firstChild} - - {childNodes.slice(1)} - - ); -} - -const MarkdownFileLink = memo(function MarkdownFileLink({ - environmentId, - href, - targetPath, - iconPath, - workspaceRelativePath, - panelPath, - line, - label, - copyMarkdown, - theme, - threadRef, - onOpen, - onOpenInPanel, - openInEditorMenuLabel, - onOpenInBrowser, - onOpenMedia, - onReveal, - revealLabel, - resolveCopyPaths, -}: MarkdownFileLinkProps) { - const handleOpenInEditor = () => { - if (!onOpen) { - return; - } - void (async () => { - try { - const result = await onOpen(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "open-file-in-editor", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "open-file-in-editor", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleOpenInFilePreview = () => { - if (threadRef && panelPath) { - onOpenInPanel(panelPath, line); - return; - } - if (onOpenMedia) { - onOpenMedia(); - return; - } - handleOpenInEditor(); - }; - - const handleOpenInBrowser = () => { - if (!onOpenInBrowser) { - return; - } - void (async () => { - try { - const result = await onOpenInBrowser(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "open-file-in-browser", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "open-file-in-browser", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleRevealInFileManager = () => { - if (!onReveal) { - return; - } - void (async () => { - try { - const result = await onReveal(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleCopy = (resolveValue: () => Promise, title: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: "Clipboard API unavailable.", - }), - ); - return; - } - - const value = resolveValue().then((text) => formatFilePathForClipboard(text, targetPath)); - // Start the write before workspace lookup finishes so Safari retains the user gesture. - const write = - typeof navigator.clipboard.write === "function" && typeof ClipboardItem !== "undefined" - ? navigator.clipboard.write([ - new ClipboardItem({ - "text/plain": value.then((text) => new Blob([text], { type: "text/plain" })), - }), - ]) - : value.then((text) => navigator.clipboard.writeText(text)); - void Promise.all([write, value]).then( - ([, text]) => { - toastManager.add({ - type: "success", - title: `${title} copied`, - description: text, - }); - }, - (error) => { - reportMarkdownActionFailure( - { operation: "copy-file-path", target: targetPath, copyTarget: title }, - error, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }; - - const showFileContextMenu = async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - - try { - const copyItems = markdownFileCopyMenuItems({ targetPath, workspaceRelativePath }); - const clicked = await api.contextMenu.show( - [ - ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), - ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), - ...(onOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) - : []), - ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), - ...copyItems.map(({ id, label }) => ({ id, label })), - ] as const, - position, - ); - - if (clicked === "preview-media") { - onOpenMedia?.(); - return; - } - if (clicked === "open") { - handleOpenInEditor(); - return; - } - if (clicked === "open-in-browser") { - handleOpenInBrowser(); - return; - } - if (clicked === "reveal") { - handleRevealInFileManager(); - return; - } - if (clicked === "copy-relative" || clicked === "copy-full") { - handleCopy( - () => - resolveCopyPaths().then((paths) => - clicked === "copy-relative" ? paths.relativePath : paths.fullPath, - ), - clicked === "copy-relative" ? "Relative path" : "Full path", - ); - } - } catch (cause) { - reportMarkdownActionFailure( - { operation: "show-file-context-menu", target: targetPath }, - cause, - ); - } - }; - - const handleContextMenu = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - const position = - event.clientX === 0 && event.clientY === 0 - ? (() => { - const bounds = event.currentTarget.getBoundingClientRect(); - return { x: bounds.left, y: bounds.bottom }; - })() - : { x: event.clientX, y: event.clientY }; - void showFileContextMenu(position); - }; - - const canOpenInEditor = onOpen !== undefined; - const canOpenInBrowser = onOpenInBrowser !== undefined; - const canOpenInPanel = threadRef !== undefined && Boolean(panelPath); - const hasPrimaryAction = hasMarkdownFilePrimaryAction({ - canOpenInEditor, - canOpenInBrowser, - canOpenInPanel, - canOpenMedia: onOpenMedia !== undefined, - }); - const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ - iconPath, - canOpenInEditor, - canOpenInBrowser, - canOpenInPanel, - }); - - return ( - - } - className={MARKDOWN_FILE_LINK_CLASS_NAME} - data-markdown-copy={copyMarkdown} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { - handleOpenInEditor(); - return; - } - if (useBrowserPrimaryAction) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - - - ) : ( - } - aria-label={`File options for ${label}`} - aria-haspopup="menu" - className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} - data-markdown-copy={copyMarkdown} - onClick={handleContextMenu} - onContextMenu={handleContextMenu} - > - - - ) - } - /> - -
- -
-
-
- ); -}, areMarkdownFileLinkPropsEqual); - -function areMarkdownFileLinkPropsEqual( - previous: Readonly, - next: Readonly, -): boolean { - return ( - previous.href === next.href && - previous.targetPath === next.targetPath && - previous.iconPath === next.iconPath && - previous.environmentId === next.environmentId && - previous.workspaceRelativePath === next.workspaceRelativePath && - previous.panelPath === next.panelPath && - previous.line === next.line && - previous.label === next.label && - previous.copyMarkdown === next.copyMarkdown && - previous.theme === next.theme && - previous.threadRef === next.threadRef && - previous.onOpen === next.onOpen && - previous.onOpenInPanel === next.onOpenInPanel && - previous.openInEditorMenuLabel === next.openInEditorMenuLabel && - previous.onOpenInBrowser === next.onOpenInBrowser && - previous.onOpenMedia === next.onOpenMedia && - previous.onReveal === next.onReveal && - previous.resolveCopyPaths === next.resolveCopyPaths && - previous.revealLabel === next.revealLabel - ); -} +const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [ + rehypeHeadingIds, + rehypeTextDirection, +] satisfies NonNullable; const NO_MARKDOWN_EDITORS: ReadonlyArray = []; @@ -2912,7 +981,11 @@ const CHAT_MARKDOWN_COMPONENTS = { return
{children}
; } return ( -
+

{ expect(result.previewAnnotations[0]?.screenshot).toBeNull(); }); }); + +describe("snapshotComposerSend", () => { + const expired = { + id: "ctx-expired", + threadId, + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "", + createdAt: now, + }; + const live = { ...expired, id: "ctx-live", text: "pnpm test", lineStart: 9, lineEnd: 9 }; + + it("drops expired terminal contexts from the contexts and from the prompt", () => { + const snapshot = snapshotComposerSend({ + prompt: ensureInlineContextReferences("run", [ + terminalContextReference(expired), + terminalContextReference(live), + ]), + images: [], + files: [], + terminalContexts: [expired, live], + sendableTerminalContexts: [live], + previewAnnotations: [], + reviewComments: [], + threadContexts: [], + }); + + expect(snapshot.terminalContexts).toEqual([live]); + expect(snapshot.messageText).toBe( + ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(), + ); + }); +}); + +describe("planComposerTurn", () => { + const base = { + messageText: "hello", + sendQueuesBehindSetup: false, + isServerThread: false, + activeMessageCount: 0, + sendEnvMode: "worktree" as const, + worktreePath: null, + activeThreadBranch: "main", + resumeCompactionTokens: null, + keepFullHistory: false, + phase: "ready" as const, + dispatchMode: "auto" as const, + }; + + it("prepares a worktree from the selected branch on a new thread", () => { + expect(planComposerTurn(base)).toMatchObject({ + missingWorktreeBaseBranch: false, + baseBranchForWorktree: "main", + shouldQueueBehindActiveRun: false, + }); + }); + + it("refuses a new worktree without a base branch", () => { + expect(planComposerTurn({ ...base, activeThreadBranch: null })).toMatchObject({ + missingWorktreeBaseBranch: true, + baseBranchForWorktree: null, + }); + }); + + it("queues behind a compaction of a stale session, except for /compact itself", () => { + expect(planComposerTurn({ ...base, resumeCompactionTokens: 120_000 })).toMatchObject({ + compactBeforeSend: true, + turnDispatchMode: "queue", + shouldQueueBehindActiveRun: true, + }); + expect( + planComposerTurn({ ...base, resumeCompactionTokens: 120_000, messageText: "/COMPACT" }), + ).toMatchObject({ compactBeforeSend: false, turnDispatchMode: "auto" }); + }); + + it("queues behind a running turn only when asked to", () => { + const running = { + ...base, + isServerThread: true, + activeMessageCount: 3, + phase: "running" as const, + }; + expect(planComposerTurn({ ...running, dispatchMode: "queue" }).shouldQueueBehindActiveRun).toBe( + true, + ); + expect(planComposerTurn({ ...running, dispatchMode: "steer" })).toMatchObject({ + shouldQueueBehindActiveRun: false, + baseBranchForWorktree: null, + }); + }); +}); + +describe("withMessageContext", () => { + const context = { records: [] } as unknown as NonNullable< + Parameters[0]["context"] + >; + + it("sends the context inline when the server supports it", () => { + expect(withMessageContext({ text: "hi", context, supportsInlineMessageContext: true })).toEqual( + { text: "hi", context }, + ); + }); + + it("folds the context into the text for older servers", () => { + const result = withMessageContext({ text: "hi", context, supportsInlineMessageContext: false }); + expect(result.context).toBeUndefined(); + expect(result.text).toContain("hi"); + }); + + it("sends the text alone without context", () => { + expect( + withMessageContext({ text: "hi", context: undefined, supportsInlineMessageContext: false }), + ).toEqual({ text: "hi" }); + }); +}); + +describe("boundAttachmentIds", () => { + it("prefers the uploaded id and falls back to the local one", () => { + expect( + boundAttachmentIds([{ id: "upload-1" }, {}], [{ id: "local-1" }, { id: "local-2" }]), + ).toEqual(["upload-1", "local-2"]); + }); +}); + +describe("deriveAvailableRevertTurnCounts", () => { + const providerThreadId = ProviderThreadId.make("provider-thread"); + const otherProviderThreadId = ProviderThreadId.make("other-provider-thread"); + + function projection(input: { readonly providerSessionId: ProviderSessionId | null }) { + const base = makeThreadProjectionFixture(); + const run = (ordinal: number) => ({ + id: RunId.make(`run-${ordinal}`), + ordinal, + activeAttemptId: `attempt-${ordinal}`, + }); + const turn = (ordinal: number, attempt: number, threadId: ProviderThreadId) => ({ + id: `turn-${attempt}-${ordinal}`, + runAttemptId: `attempt-${attempt}`, + ordinal, + providerThreadId: threadId, + }); + return { + ...base, + thread: { ...base.thread, activeProviderThreadId: providerThreadId }, + providerThreads: [ + { + id: providerThreadId, + providerInstanceId: base.thread.modelSelection.instanceId, + providerSessionId: input.providerSessionId, + }, + ], + runs: [run(1), run(2), run(3)], + attempts: [1, 2, 3].map((ordinal) => ({ id: `attempt-${ordinal}`, providerTurnId: null })), + // Run 2's latest turn moved to another provider thread, so its checkpoint cannot restore + // this conversation. + providerTurns: [ + turn(1, 1, providerThreadId), + turn(1, 2, providerThreadId), + turn(2, 2, otherProviderThreadId), + turn(1, 3, providerThreadId), + ], + } as unknown as ReturnType; + } + + it("offers the thread start and runs whose latest turn is on the active provider thread", () => { + const available = deriveAvailableRevertTurnCounts( + projection({ providerSessionId: ProviderSessionId.make("session") }), + ); + expect([...available].toSorted()).toEqual([0, 1, 3]); + }); + + it("offers nothing without a provider session to rewind", () => { + expect(deriveAvailableRevertTurnCounts(projection({ providerSessionId: null })).size).toBe(0); + expect(deriveAvailableRevertTurnCounts(null).size).toBe(0); + }); +}); + +describe("deriveWorkspaceMutationId", () => { + const item = (id: string, type: string, status: string) => + ({ + item: { id: TurnItemId.make(id), type, status }, + }) as unknown as OrchestrationV2ProjectedTurnItem; + + it("keys on the latest finished command or file change and the latest checkpoint", () => { + expect( + deriveWorkspaceMutationId( + [ + item("edit", "file_change", "completed"), + item("reply", "assistant_message", "completed"), + item("command", "command_execution", "running"), + ], + "2026-01-01T00:00:00.000Z", + ), + ).toBe(JSON.stringify(["edit", "2026-01-01T00:00:00.000Z"])); + }); + + it("is null before anything could have touched the workspace", () => { + expect(deriveWorkspaceMutationId([item("reply", "assistant_message", "completed")], null)).toBe( + null, + ); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 36f0feda28464..1928cd21b12c1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,5 +1,22 @@ +import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts"; +import { + deriveLatestThreadRun, + deriveThreadRuntime, +} from "@t3tools/client-runtime/state/thread-execution"; +import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { + latestUnheldRun, + usageLimitRunPresentedAsLatest, +} from "@t3tools/shared/orchestrationV2ThreadError"; import * as Option from "effect/Option"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { SetStateAction } from "react"; +import { applyClaudePromptEffortPrefix, resolvePromptInjectedEffort } from "@t3tools/shared/model"; +import { getProviderModelCapabilities } from "../providerModels"; +import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; +import type { + EnvironmentThread, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; import { ANTIGRAVITY_DEFAULT_MODEL, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -12,6 +29,7 @@ import { ProjectId, type MessageId, type ModelSelection, + type OrchestrationMessageContext, type OrchestrationV2ProjectedTurnItem, type PreviewAnnotationPayload, type ProviderInteractionMode, @@ -48,13 +66,32 @@ import { type Thread, type TurnDiffSummary, } from "../types"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerFileAttachment, + type ComposerImageAttachment, + type DraftId, + type DraftThreadState, +} from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadShells, environmentThreadDetails } from "../state/threads"; import { waitForAtomValue } from "../state/waitForAtomValue"; -import { filterTerminalContextsWithText, type TerminalContextDraft } from "../lib/terminalContext"; -import { stripInlineContextReferences } from "~/lib/composerContextReferences"; +import { + filterTerminalContextsWithText, + formatTerminalContextLabel, + type TerminalContextDraft, +} from "../lib/terminalContext"; +import { + removeInlineContextReference, + stripInlineContextReferences, +} from "~/lib/composerContextReferences"; +import { + previewAnnotationContextLabel, + reviewCommentContextLabel, + terminalContextReference, +} from "../lib/composerContextRecords"; +import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; +import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic"; import type { ReviewCommentContext } from "../reviewCommentContext"; @@ -68,6 +105,8 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; +type ThreadProjection = EnvironmentThread["projection"]; + export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -1335,3 +1374,387 @@ export function mergeDirectPreviewAnnotation(input: { } export { isThreadWokeUnseen } from "@t3tools/client-runtime/state/thread-settled"; + +export function formatOutgoingPrompt(params: { + provider: ProviderDriverKind; + model: string | null; + models: ReadonlyArray; + effort: string | null; + text: string; +}): string { + const caps = getProviderModelCapabilities(params.models, params.model, params.provider); + const promptEffort = resolvePromptInjectedEffort(caps, params.effort); + return applyClaudePromptEffortPrefix(params.text, promptEffort); +} + +export function isCompactCommandMessage(message: ChatMessage): boolean { + const text = message.text.trim().toLowerCase(); + return message.role === "user" && text === "/compact" && !message.attachments?.length; +} + +// Errors surface through two maps (draft-keyed and thread-keyed) whose entries +// can race around promotion, so each write carries its time to let the latest +// one win when they collide. +export type LocalThreadErrorEntry = { + readonly message: string | null; + readonly at: number; +}; + +export function chatActionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "An error occurred."; +} + +export function releaseChatTimelineAnchor( + current: T, +): T { + return current.messageId === null ? current : { ...current, messageId: null }; +} + +// The factories below build functions ChatView calls during render, or hands +// to ChatComposer, which can call them after an await. They live outside the +// component so each closure keeps only its inputs, not a whole render scope +// (see useStableCallback), while still acting on the render that made them. + +/** Records a thread error in local state, keyed by the route the making render saw. */ +export function makeThreadErrorSetter(input: { + readonly routeThreadRef: ScopedThreadRef; + readonly routeThreadKey: string; + readonly draftId: DraftId | null; + readonly serverThreadRef: ScopedThreadRef | null; + readonly setLocalServerErrorsByThreadKey: ( + update: SetStateAction>, + ) => void; + readonly setLocalDraftErrorsByDraftId: ( + update: SetStateAction>, + ) => void; +}) { + return (targetThreadId: ThreadId | null, error: string | null) => { + if (!targetThreadId) return; + const nextError = sanitizeThreadErrorMessage(error); + const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; + const { routeThreadRef, serverThreadRef } = input; + if ( + serverThreadRef && + targetThreadId === routeThreadRef.threadId && + serverThreadRef.environmentId === routeThreadRef.environmentId && + serverThreadRef.threadId === targetThreadId + ) { + input.setLocalServerErrorsByThreadKey((existing) => { + if ((existing[input.routeThreadKey]?.message ?? null) === nextError) { + return existing; + } + return { ...existing, [input.routeThreadKey]: nextEntry }; + }); + return; + } + const localDraftErrorKey = input.draftId ?? targetThreadId; + input.setLocalDraftErrorsByDraftId((existing) => { + if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { + return existing; + } + return { ...existing, [localDraftErrorKey]: nextEntry }; + }); + }; +} + +/** Why the model picker should disable a model, or null when it can be chosen. */ +export function makeModelDisabledReason( + context: Omit< + Parameters[0], + "nextModelSelection" + > | null, +) { + return (instanceId: ProviderInstanceId, model: string): string | null => { + if (!context) return null; + const reason = getStartedThreadModelChangeBlockReason({ + ...context, + nextModelSelection: { instanceId, model }, + }); + return reason ? `${reason.description} Start a new thread to use this model.` : null; + }; +} + +/** What the composer held when the user pressed send, after a direct annotation merged in. */ +export interface ComposerSendContents { + readonly prompt: string; + readonly images: ReadonlyArray; + readonly files: ReadonlyArray; + readonly terminalContexts: ReadonlyArray; + readonly sendableTerminalContexts: ReadonlyArray; + readonly previewAnnotations: ReadonlyArray; + readonly reviewComments: ReadonlyArray; + readonly threadContexts: ReadonlyArray; +} + +/** + * Copies the composer contents a send dispatches, so a failed send can put the + * same draft back. Terminal contexts that lost their text are dropped from both + * the contexts and the prompt's inline references. + */ +export function snapshotComposerSend(contents: ComposerSendContents) { + const images = [...contents.images]; + const files = [...contents.files]; + const terminalContexts = [...contents.sendableTerminalContexts]; + return { + images, + files, + attachments: [...images, ...files], + terminalContexts, + previewAnnotations: [...contents.previewAnnotations], + reviewComments: [...contents.reviewComments], + threadContexts: [...contents.threadContexts], + messageText: contents.terminalContexts + .filter((context) => !terminalContexts.includes(context)) + .reduce( + (text, context) => + removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, + contents.prompt, + ) + .trim(), + }; +} + +export type ComposerSendSnapshot = ReturnType; + +/** How a composer send dispatches: where it runs and whether it waits behind other work. */ +export function planComposerTurn(input: { + readonly messageText: string; + readonly sendQueuesBehindSetup: boolean; + readonly isServerThread: boolean; + readonly activeMessageCount: number; + readonly sendEnvMode: DraftThreadEnvMode; + readonly worktreePath: string | null; + readonly activeThreadBranch: string | null; + readonly resumeCompactionTokens: number | null; + readonly keepFullHistory: boolean; + readonly phase: SessionPhase; + readonly dispatchMode: ComposerDispatchMode; +}) { + const isFirstMessage = + !input.sendQueuesBehindSetup && (!input.isServerThread || input.activeMessageCount === 0); + const shouldCreateWorktree = + isFirstMessage && input.sendEnvMode === "worktree" && !input.worktreePath; + // A stale Claude session compacts before this message so the turn does not re-read the + // old history. The message queues behind the /compact run, since steering into it is + // rejected. + const compactBeforeSend = + input.resumeCompactionTokens !== null && + !input.keepFullHistory && + input.messageText.toLowerCase() !== "/compact"; + return { + // In worktree mode, require an explicit base branch so we don't silently + // fall back to local execution when branch selection is missing. + missingWorktreeBaseBranch: shouldCreateWorktree && !input.activeThreadBranch, + baseBranchForWorktree: shouldCreateWorktree ? input.activeThreadBranch : null, + compactBeforeSend, + turnDispatchMode: + compactBeforeSend || input.sendQueuesBehindSetup ? ("queue" as const) : input.dispatchMode, + shouldQueueBehindActiveRun: + compactBeforeSend || + input.sendQueuesBehindSetup || + (input.phase === "running" && input.dispatchMode === "queue"), + }; +} + +/** + * The attachment ids a message's context records bind to: the upload id the + * server returned, or the local id on the data-URL path. + */ +export function boundAttachmentIds( + turnAttachments: ReadonlyArray<{ readonly id?: string | undefined }>, + attachments: ReadonlyArray<{ readonly id: string }>, +): string[] { + return turnAttachments.map((attachment, index) => + "id" in attachment && attachment.id !== undefined ? attachment.id : attachments[index]!.id, + ); +} + +/** + * The message text and context a turn sends. Servers from before inline + * context drop the records and forward the links as literal text, so their + * turns carry the payload the legacy way. + */ +export function withMessageContext(input: { + readonly text: string; + readonly context: OrchestrationMessageContext | undefined; + readonly supportsInlineMessageContext: boolean; +}): { readonly text: string; readonly context?: OrchestrationMessageContext } { + const { text, context } = input; + if (context === undefined) return { text }; + if (!input.supportsInlineMessageContext) { + return { text: serializeLegacyContextMessage({ text, records: context.records }) }; + } + return { text, context }; +} + +/** The attachments the optimistic user row shows before the server echoes the message. */ +export function optimisticMessageAttachments( + attachments: ReadonlyArray, +) { + return attachments.map((attachment) => + attachment.type === "image" + ? { + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.previewUrl, + ...(attachment.source ? { source: attachment.source } : {}), + } + : { + type: "file" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + downloadable: false, + ...(attachment.source ? { source: attachment.source } : {}), + }, + ); +} + +/** Fallback title sources for a message with no text of its own. */ +export function sendTitleFallbackLabels(snapshot: ComposerSendSnapshot) { + const terminalContext = snapshot.terminalContexts[0]; + const reviewComment = snapshot.reviewComments[0]; + const previewAnnotation = snapshot.previewAnnotations[0]; + return [ + terminalContext ? formatTerminalContextLabel(terminalContext) : undefined, + reviewComment ? `Review: ${reviewCommentContextLabel(reviewComment)}` : undefined, + previewAnnotation ? previewAnnotationContextLabel(previewAnnotation) : undefined, + ]; +} + +/** A thread shell built from its loaded detail, for direct links that hydrate the detail first. */ +export function presentThreadShellFromProjection( + environmentId: EnvironmentId, + threadId: ThreadId, + projection: ThreadProjection, +) { + const latestRun = deriveLatestThreadRun(projection); + const runtime = deriveThreadRuntime(projection); + const pendingRuntimeRequest = + projection.runtimeRequests.findLast((request) => request.status === "pending") ?? null; + // Direct links can hydrate detail before the thread enters the shell index. + return { + ...presentThreadShell(environmentId, { + ...projection.thread, + latestRunId: latestRun?.runId ?? null, + activeRunId: runtime?.activeRunId ?? null, + status: runtime?.status ?? "idle", + pendingRuntimeRequest, + latestVisibleMessage: null, + latestUserMessageAt: + projection.messages.findLast((message) => message.role === "user")?.updatedAt ?? null, + hasActionableProposedPlan: projection.plans.some( + (plan) => plan.kind === "proposed_plan" && plan.status === "active", + ), + providerInstanceHistory: [ + ...new Set( + projection.providerThreads + .filter((thread) => thread.appThreadId === threadId && thread.ownerNodeId === null) + .map((thread) => thread.providerInstanceId), + ), + ], + goal: + projection.providerThreads.find( + (thread) => thread.id === projection.thread.activeProviderThreadId, + )?.goal ?? null, + itemCount: projection.turnItems.length, + visibleItemCount: projection.visibleTurnItems.length, + updatedAt: projection.updatedAt, + }), + latestRun, + runtime, + }; +} + +/** Background work the provider still runs after the latest run ended. */ +export function deriveThreadPendingBackgroundTasks(projection: ThreadProjection | null) { + if (projection === null) return []; + const sessionError = + projection.providerSessions.findLast( + (session) => session.providerInstanceId === projection.thread.providerInstanceId, + )?.lastError ?? null; + const latestRun = + usageLimitRunPresentedAsLatest(projection.runs, projection.turnItems, sessionError) ?? + latestUnheldRun(projection.runs); + return [ + ...derivePendingBackgroundWork({ + latestRun, + providerThreads: projection.providerThreads, + turnItems: projection.turnItems, + activeProviderThreadId: projection.thread.activeProviderThreadId, + runs: projection.runs, + pullRequests: projection.thread.pullRequests, + }), + ]; +} + +/** + * Run ordinals the conversation can be reverted to: those whose provider turn belongs to the + * active provider thread, plus 0 for the thread start. Empty without a live provider session + * on the thread's selected instance. + */ +export function deriveAvailableRevertTurnCounts(projection: ThreadProjection | null) { + const available = new Set(); + const providerThread = projection?.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ); + if ( + !providerThread?.providerSessionId || + providerThread.providerInstanceId !== projection?.thread.modelSelection.instanceId + ) + return available; + available.add(0); + const attemptsById = new Map(projection.attempts.map((attempt) => [attempt.id, attempt])); + const providerTurnsById = new Map< + OrchestrationV2ProviderTurn["id"], + OrchestrationV2ProviderTurn + >(); + const latestTurnByAttempt = new Map(); + for (const turn of projection.providerTurns) { + providerTurnsById.set(turn.id, turn); + if (turn.runAttemptId === null) continue; + const latest = latestTurnByAttempt.get(turn.runAttemptId); + if (latest === undefined || turn.ordinal > latest.ordinal) { + latestTurnByAttempt.set(turn.runAttemptId, turn); + } + } + for (const run of projection.runs) { + if (run.activeAttemptId === null) continue; + const attempt = attemptsById.get(run.activeAttemptId); + const providerTurn = + latestTurnByAttempt.get(run.activeAttemptId) ?? + (attempt?.providerTurnId == null ? undefined : providerTurnsById.get(attempt.providerTurnId)); + // A ready file checkpoint alone cannot restore the provider conversation. + if (providerTurn?.providerThreadId === providerThread.id) available.add(run.ordinal); + } + return available; +} + +/** + * Changes when the agent may have changed the workspace, so file views refresh. File tools are + * explicit; completed commands are included because a shell command can mutate the workspace + * without reporting the paths it touched. + */ +export function deriveWorkspaceMutationId( + visibleTurnItems: ReadonlyArray, + latestCheckpointCompletedAt: string | null, +) { + let itemId: string | null = null; + for (let index = visibleTurnItems.length - 1; index >= 0; index -= 1) { + const item = visibleTurnItems[index]?.item; + if (!item) continue; + if (item.type !== "command_execution" && item.type !== "file_change") continue; + if (item.status === "pending" || item.status === "running" || item.status === "waiting") { + continue; + } + itemId = item.id; + break; + } + return itemId === null && latestCheckpointCompletedAt === null + ? null + : JSON.stringify([itemId, latestCheckpointCompletedAt]); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0b9a6d854a72a..8e26698758061 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,9 +3,7 @@ import { PlanReviewPanel, useOpenPlanReviewDocumentId } from "../fork/planReview import { PlanReviewTakeover } from "../fork/planReviewTakeover"; import { PlanReviewComposerDock } from "../fork/planReviewComposerDock"; import { - THREAD_COMMENTS_EMPTY_SEND_TEXT, ThreadCommentsPanel, - threadCommentsEmptySendAllowed, useThreadCommentsChatView, } from "../fork/threadComments/threadCommentsSurface"; import { AgentUiExpandedSurface } from "../fork/agentUiSurface"; @@ -15,43 +13,48 @@ import { useRightPanelMaximizedPreference } from "../rightPanelLayoutPreference" import { useCurrentUserId } from "../state/identity"; import { useDurableThreadOutbox } from "../fork/useDurableThreadOutbox"; import { DurableOutboxRecovery } from "../fork/DurableOutboxRecovery"; -import { - resolveThreadCreationProfile, - threadCreationForkFields, -} from "../fork/threadCreationMetadata"; +import { useOperatorStartTurn, useOutboxIdentityKey } from "../fork/useOperatorStartTurn"; +import { resolveThreadCreationProfile } from "../fork/threadCreationMetadata"; +import { sourceControlEnvironment } from "../state/sourceControl"; // T3-CUSTOM(expbkt3): END -import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; -import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; +import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; +import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; +import { + deriveAvailableRevertTurnCounts, + deriveThreadPendingBackgroundTasks, + deriveWorkspaceMutationId, + presentThreadShellFromProjection, +} from "./ChatView.logic"; +import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread"; +import { useComposerSelection } from "./chat/useComposerSelection"; +import { useProjectCloneBanner } from "./chat/useProjectCloneBanner"; +import { useAttachmentPreviewHandoff } from "./chat/useAttachmentPreviewHandoff"; +import { useTimelineRewind } from "./chat/useTimelineRewind"; +import { useThreadLifecycle } from "./chat/useThreadLifecycle"; +import { useRightPanelActions } from "./chat/useRightPanelActions"; +import { type TerminalLaunchContext, useChatTerminalActions } from "./chat/useChatTerminalActions"; +import { useComposerSend } from "./chat/useComposerSend"; +import { useComposerResume } from "./chat/useComposerResume"; +import { useImplementPlanInNewThread } from "./chat/useProposedPlanSend"; import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider"; import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; -import { - isThreadWokeUnseen, - mergeDirectPreviewAnnotation, - resolveBackgroundDraftWorkspaceOptions, - resolveDraftHeroState, - shouldDockDraftHeroForSubmission, -} from "./ChatView.logic"; -import { restorePlanFollowUpComposer } from "./ChatView.logic"; +import { resolveDraftHeroState } from "./ChatView.logic"; import { useQueuedRunEdit } from "./chat/useQueuedRunEdit"; import { isPaintOnlyThreadTimeline, peekHeldThreadTimeline, peekRememberedThreadTimeline, rememberReadyThreadTimeline, - resolveExplicitDiffOpenOnActivate, resolveThreadSwitchTimeline, timelineHasEphemeralPreviewUrls, recallCheckoutIsRepo, rememberCheckoutIsRepo, } from "./ChatView.logic"; -import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; -import { useScratchProject } from "../hooks/useScratchProject"; import { useStableCallback } from "../hooks/useStableCallback"; import { useProjectScriptEditing } from "./chat/useProjectScriptEditing"; import { RightPanelSurfaceContent } from "./chat/RightPanelSurfaceContent"; -import { useThreadTerminalActions } from "./terminal/useThreadTerminalActions"; import { usePendingThreadRequests } from "./chat/usePendingThreadRequests"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; @@ -60,63 +63,33 @@ import { latestRootProviderFailure, } from "@t3tools/shared/orchestrationV2ThreadError"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; -import { isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { feedbackBannerItem } from "./chat/ComposerFeedback"; import { useUsageLimitsPanel } from "./chat/useUsageLimitsPanel"; import { useWorktreeSetup } from "./chat/useWorktreeSetup"; -import { useChatShortcuts, type ChatShortcutHandler } from "./chat/useChatShortcuts"; -import { - eventPathContainsSelector, - pasteTextToFocusComposer, - shouldRedirectInputToComposer, - TYPE_TO_FOCUS_EDITABLE_SELECTOR, - TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR, -} from "./chat/composerFocusRedirect"; -import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { useChatShortcuts } from "./chat/useChatShortcuts"; +import { ChatBannerLane } from "./chat/ChatBannerLane"; import * as Schema from "effect/Schema"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, AuthPreviewOperateScope, - AuthTerminalReadScope, AuthTerminalOperateScope, type AssistantCitation, type ChatFileAttachment, - CommandId, - DEFAULT_MODEL, isProviderNativeSubagentThread, type EnvironmentId, type MessageId, type ModelSelection, - type OrchestrationV2ProviderTurn, - type ProjectScript, type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, type ScopedThreadRef, type ThreadId, type RunId, - type RunAttemptId, - type RuntimeRequestId, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, - ProviderDriverKind, - resolveProjectProviderInstanceEnabled, + resolveIdleCompaction, RuntimeMode, } from "@t3tools/contracts"; -import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; -import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; -import { - wasBootstrapThreadDeleted, - wasBootstrapThreadNotCreated, -} from "@t3tools/client-runtime/errors"; -import { readPastedComposerContext } from "./composerInlineTokenPaste"; -import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; -import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; -import { declinePendingApprovals } from "@t3tools/client-runtime/state/thread-requests"; -import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads"; -import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; import { deriveProviderSubagentStatus, deriveReportedModelSelection, @@ -125,8 +98,6 @@ import { deriveThreadActivityRun, deriveLatestThreadRun, deriveThreadRuntime, - presentPendingBackgroundWork, - presentProviderGoal, } from "@t3tools/client-runtime/state/thread-execution"; import { resolveWorkflowOpening, @@ -137,82 +108,41 @@ import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { codexFeedbackMessage, - parseCodexFeedbackCommand, shouldShowLoadEarlierControl, - submitCodexFeedback, type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; -import { resolveThreadLastVisitedAt } from "./Sidebar.logic"; import { parseScopedThreadKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { - applyClaudePromptEffortPrefix, - createModelSelection, - formatModelSlugName, - resolvePromptInjectedEffort, - resolveSelectableModel, -} from "@t3tools/shared/model"; -import { - projectScriptCwd, - projectScriptTerminalInput, - resolveProjectScripts, -} from "@t3tools/shared/projectScripts"; -import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; -import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; -import { - latestUnheldRun, - usageLimitRunPresentedAsLatest, -} from "@t3tools/shared/orchestrationV2ThreadError"; +import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; +import { projectScriptCwd, resolveProjectScripts } from "@t3tools/shared/projectScripts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; -import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; -import { Atom } from "effect/reactivity"; // T3-CUSTOM(expbkt3): fork code still uses Suspense. -import { - Fragment, - type SetStateAction, - Suspense, - useEffect, - useEffectEvent, - useLayoutEffect, - useRef, - useState, -} from "react"; -import { flushSync } from "react-dom"; -import { useLocation, useNavigate, useRouter } from "@tanstack/react-router"; +import { type SetStateAction, Suspense, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; -import { isMacPlatform } from "../lib/utils"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { isAtomCommandInterrupted, mapAtomCommandResult, - settlePromise, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { isElectron } from "../env"; -import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; -import { - type ComposerSubmissionIntent, - collapseExpandedComposerCursor, - composerRequiresModifier, - parseStandaloneComposerSlashCommand, -} from "../composer-logic"; +import { composerRequiresModifier } from "../composer-logic"; import { derivePhase, - selectHandoffImageResources, deriveActivePlanState, selectComposerTasksPlan, deriveActiveWorkStartedAt, @@ -223,36 +153,13 @@ import { isLatestRunSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { - CHAT_TIMELINE_ANCHOR_OFFSET, - timelineContentOverflowsViewport, - readTimelinePosition, - observeTimelineRun, - type TimelineRunObservation, - type TimelineScrollMode, -} from "./chat/timelineScrollAnchoring"; -import { useUiStateStore } from "../uiStateStore"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; -import { - buildPlanImplementationThreadTitle, - buildPlanImplementationPrompt, - resolvePlanFollowUpSubmission, -} from "../proposedPlan"; -import { - DEFAULT_INTERACTION_MODE, - MAX_TERMINALS_PER_GROUP, - type ChatMessage, - type SessionPhase, - type Thread, -} from "../types"; +import { DEFAULT_INTERACTION_MODE, type ChatMessage } from "../types"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; -import { isCommandPaletteOpen } from "../commandPaletteBus"; -import { settleAndAdvance } from "../settleAndAdvance"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider"; -import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, type ThreadPanelPresentation, @@ -263,31 +170,20 @@ import { selectActiveRightPanelSurface, selectThreadPanelOpen, selectThreadRightPanelState, - type RightPanelSurface, useRightPanelStore, } from "../rightPanelStore"; -import { - isPreviewSupportedInRuntime, - setActivePreviewTab, - useThreadPreviewState, -} from "../previewStateStore"; -import { BrowserSettingsReadError, openUrlInPreview } from "../browser/openFileInPreview"; -import { resolveDiscoveredServerUrl } from "../browser/browserTargetResolver"; +import { isPreviewSupportedInRuntime, useThreadPreviewState } from "../previewStateStore"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; -import { addBrowserSurface } from "./preview/addBrowserSurface"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { usePreviewSession } from "./preview/usePreviewSession"; -import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { - browserMiniPlayerSource, previewMiniPlayerSourceKey, selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { threadPullRequestPanelTarget } from "./pullRequest/pullRequestDetail.logic"; -import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { useDeviceState } from "~/state/device"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; @@ -295,26 +191,12 @@ import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { shortcutLabelForCommand } from "../keybindings"; -import { PersistentThreadTerminalDrawer } from "./PersistentThreadTerminals"; -import { - AlarmClockIcon, - CheckCircle2Icon, - MessageCircleIcon, - PaperclipIcon, - ChevronDownIcon, - DownloadIcon, - GitBranchIcon, - ShieldQuestionIcon, - TargetIcon, - WifiOffIcon, -} from "lucide-react"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; -import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { newMessageId } from "~/lib/utils"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; -import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -330,33 +212,18 @@ import { import { useNowMinute } from "../hooks/useNowMinute"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; -import { resolveAppModelSelectionForInstance } from "../modelSelection"; -import { - getComposerPromptInjectionState, - getComposerProviderState, -} from "./chat/composerProviderState"; -import { confirmTerminalClose } from "../lib/terminalCloseConfirm"; import { isPreviewFocused } from "../lib/previewFocus"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; -import { findReusableProjectScriptTerminal } from "../lib/projectScriptTerminal"; -import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { isSameSidebarThreadRef, useSidebarPendingFileDropStore, } from "../sidebarPendingFileDropStore"; import { - beginBackgroundDraftSubmissionByRef, - clearBackgroundDraftSubmissionByRef, - finalizePromotedDraftThreadByRef, - markPromotedDraftThreadByRef, - restoreFailedDraftThread, composerDraftHasUserContent, type ComposerFileAttachment, type ComposerImageAttachment, @@ -364,35 +231,13 @@ import { useComposerDraftStore, DraftId, } from "../composerDraftStore"; -import { - formatTerminalContextLabel, - type TerminalContextDraft, - type TerminalContextSelection, -} from "../lib/terminalContext"; -import { - collectInlineContextIds, - ensureInlineContextReferences, - removeInlineContextReference, -} from "../lib/composerContextReferences"; -import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; -import { - buildMessageContext, - previewAnnotationContextLabel, - previewAnnotationContextReference, - reviewCommentContextLabel, - terminalContextReference, -} from "../lib/composerContextRecords"; -import { type ReviewCommentContext } from "../reviewCommentContext"; -import { environmentCatalog } from "../connection/catalog"; -import { ConnectionDiagnosisHeadline } from "./settings/ConnectionDiagnosisDetails"; -import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; -import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; +import { type TerminalContextDraft } from "../lib/terminalContext"; +import { collectInlineContextIds } from "../lib/composerContextReferences"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; import { useEnvironmentScope } from "~/state/session"; import { - environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, serverEnvironment, @@ -400,10 +245,6 @@ import { import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; -import { sourceControlEnvironment } from "../state/sourceControl"; -import { useProjectClone } from "../state/projectClones"; -import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts"; -import { dismissProjectFileCopy, useProjectFileCopy } from "../lib/addProjectToMachine"; import { type EnvironmentPresentation, useEnvironmentProviders, @@ -414,32 +255,21 @@ import { resolveThreadDetailRef, useEnvironmentSupportsServerBrowser, useProject, - useProjects, useThreadProjection, useThreadStatus, useThreadHistory, useQuietThreadShell, - useThreadShell, - useChildThreadInputs, - useThreadRefs, useThreadVisibleTurnItems, useThreadCheckpointSummaries, - readThreadShell, - waitForThreadShell, } from "../state/entities"; -import { environmentShell, useEnvironmentShellHasSnapshot } from "../state/shell"; +import { useEnvironmentShellHasSnapshot } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; -import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; -import { createUpwardScrollDetector, isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialogHost, openExpandedImagePreview } from "./chat/ExpandedImageDialog"; import { BranchRestoreConfirmHost, DeviceSetupDialogHost, openBranchRestoreConfirm, - openDeviceSetupDialog, - openPullRequestThreadDialog, - openRevertConfirm, PullRequestThreadDialogHost, RevertConfirmHost, } from "./chat/ChatOverlayHosts"; @@ -447,7 +277,6 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; -import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { overlayComposerIsResting, resolveComposerTimelineInset, @@ -460,7 +289,8 @@ import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; import { PanelLayoutControls, type PanelLayoutControlsProps, - RightPanelMaximizeControl, + ThreadPanelHeaderControl, + WorkspaceTitlebarControls, } from "./chat/PanelLayoutControls"; import type { ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { ThreadDetailsPanel } from "./chat/ThreadDetailsPanel"; @@ -473,15 +303,12 @@ import { } from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, - ProviderStatusBanner, shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { - dismissThreadErrorBannerForSession, getThreadErrorBannerKey, isThreadErrorBannerDismissedForSession, shouldShowThreadErrorBanner, - ThreadErrorBanner, } from "./chat/ThreadErrorBanner"; import { QueuedRunsControl, type QueuedRunsControlHandle } from "./chat/QueuedRunsControl"; import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullRequest"; @@ -490,8 +317,6 @@ import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEn import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; import { useAgentOpenedPreviews } from "./preview/useAgentOpenedPreviews"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; -import { ThreadStatusLine } from "./chat/ThreadStatusLine"; -import { formatRelativeTimeLabel, formatRelativeTimeUntilLabel } from "../timestampFormat"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; import { @@ -504,409 +329,66 @@ import { latestProviderTurnTokenUsage, } from "../lib/contextWindow"; import { - DRAFT_HERO_TRANSITION_ANIMATION_ID, - DRAFT_HERO_TRANSITION_EASING, MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, - runMobileComposerTransition, } from "./chat/draftHeroTransition"; -import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - branchMismatchKey, - buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, - createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, - deriveComposerSendState, dismissBranchMismatchForSession, - hasEnvironmentReconnectWarningGraceElapsed, - scheduleEnvironmentReconnectWarning, - hasServerAcknowledgedLocalDispatch, - isBranchMismatchDismissedForSession, - shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, - shouldReleaseTimelineAnchorForToolActivity, shouldRenderPreviewMiniPlayer, - getStartedThreadModelChangeBlockReason, - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - LastInvokedScriptByProjectSchema, - type LocalDispatchSnapshot, - cloneComposerImageForRetry, deriveLockedProvider, - readFileAsDataUrl, resolveFileAttachmentUrl, - prepareRevertedMessageAttachments, - waitForRevertedMessage, - reconcileMountedTerminalThreadIds, resolveComposerInteractionMode, resolveComposerProviderSelection, - getAntigravitySendBlockReason, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, - revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, startNewThreadForProject, codexArtifactTemplatePromptToAppend, - waitForStartedServerThread, shouldRefocusComposerOnWindowFocus, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; -import { - awaitAttachmentUploads, - getUploadedAttachments, - readAttachmentUpload, - releaseDraftAttachments, - retryAttachmentUpload, - startAttachmentUpload, -} from "../lib/attachmentUploadQueue"; -import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; -import { RightPanelSheet } from "./RightPanelSheet"; -import { previewEnvironment } from "../state/preview"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; import { assetEnvironment } from "../state/assets"; import { readEnvironmentScope, readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; -import { Button, InlineButton } from "./ui/button"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { ServerUpdateAction, serverUpdateStageLabel } from "./ServerUpdateAction"; -import { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner"; -import { ComposerServerUpdateIcon } from "./chat/ComposerServerUpdateStatus"; +import { observeResize } from "~/lib/observeResize"; +import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; +import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; +import { ChatRightPanel } from "./chat/ChatRightPanel"; +import { useDraftEnvironmentRouting } from "./chat/useDraftEnvironmentRouting"; +import { branchMismatchBannerItem } from "./chat/ThreadActivityBanners"; +import { useTimelineFollow } from "./chat/useTimelineFollow"; import { - buildVersionMismatchDismissalKey, - dismissServerUpdateFailure, - dismissVersionMismatch, - isServerUpdateFailureDismissed, - isVersionMismatchDismissed, - resolveServerConfigVersionMismatch, - resolveServerSelfUpdateCapability, - serverUpdateGuidance, - supportsDesktopAppUpdate, - supportsServerUpdateThreadContinuation, -} from "../versionSkew"; -import { useAssetUrls } from "../assets/assetUrls"; + type EnvironmentUnavailableState, + useSystemComposerBanners, +} from "./chat/useSystemComposerBanners"; +import { useDraftHeroLayoutTransition } from "./chat/useDraftHeroLayoutTransition"; +import { useLocalDispatchState } from "./chat/useLocalDispatchState"; +import { draftFanoutSelectionsAtom, draftSendState } from "./chat/chatViewSend"; import { - ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, - threadContextsOutsideEnvironment, -} from "@t3tools/shared/composerContextReferences"; -import { recallableComposerPrompt } from "./chat/composerPromptHistory"; -import { observeResize } from "~/lib/observeResize"; + isCompactCommandMessage, + type LocalThreadErrorEntry, + makeThreadErrorSetter, +} from "./ChatView.logic"; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; -// T3-CUSTOM(expbkt3): product analytics (TEC-1631). -import { lastInputWasKeyboard, trackProductEvent } from "../lib/analytics/productAnalytics"; -const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; -// During an active turn the thread's updatedAt advances several times per -// second, and every server-side visit is a full command dispatch plus a -// broadcast to all shell subscribers. -const VISIT_DISPATCH_THROTTLE_MS = 10_000; -const RELATED_THREAD_SHELL_WAIT_MS = 1_000; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray = []; -function useDraftHeroLayoutTransition( - isDraftHeroState: boolean, - animationsActive: boolean, - animationDurationMs: number, -) { - const transitionGroupRef = useRef(null); - const composerAnchorRef = useRef(null); - const previousStateRef = useRef(isDraftHeroState); - const previousComposerRectRef = useRef(null); - const animationRef = useRef(null); - const captureComposerRect = useStableCallback(() => { - previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null; - }); - - useLayoutEffect(() => { - const transitionGroup = transitionGroupRef.current; - const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; - const stateChanged = previousStateRef.current !== isDraftHeroState; - const mobileComposerTransitionActive = - typeof document !== "undefined" && - document.documentElement.dataset.mobileComposerRouteTransition === "true"; - - animationRef.current?.cancel(); - animationRef.current = null; - const previousComposerRect = previousComposerRectRef.current; - if ( - stateChanged && - animationsActive && - !mobileComposerTransitionActive && - transitionGroup && - previousComposerRect && - nextComposerRect && - typeof transitionGroup.animate === "function" - ) { - const translateX = previousComposerRect.left - nextComposerRect.left; - const translateY = previousComposerRect.top - nextComposerRect.top; - if (Math.abs(translateX) >= 0.5 || Math.abs(translateY) >= 0.5) { - const animation = transitionGroup.animate( - [ - { transform: `translate3d(${translateX}px, ${translateY}px, 0)` }, - { transform: "translate3d(0, 0, 0)" }, - ], - { - duration: animationDurationMs, - easing: DRAFT_HERO_TRANSITION_EASING, - }, - ); - animation.id = DRAFT_HERO_TRANSITION_ANIMATION_ID; - animationRef.current = animation; - void animation.finished - .catch(() => undefined) - .then(() => { - if (animationRef.current === animation) animationRef.current = null; - }); - } - } - previousStateRef.current = isDraftHeroState; - previousComposerRectRef.current = nextComposerRect; - }, [animationDurationMs, animationsActive, isDraftHeroState]); - - return { transitionGroupRef, composerAnchorRef, captureComposerRect } as const; -} const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); -type EnvironmentUnavailableState = { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly connection: EnvironmentConnectionPresentation; -}; - -// Kept per route so a send in flight, and the multiple-model selections, survive -// ChatView remounting while the user navigates. -const draftFanoutSelectionsAtom = Atom.family((_routeKey: string) => - Atom.make | null>(null).pipe(Atom.keepAlive), -); -const draftSendStateByRouteKey = new Map< - string, - { - readonly sendInFlight: { current: boolean }; - readonly uncertainSubmissions: { current: Map }; - } ->(); -function draftSendState(routeKey: string) { - let state = draftSendStateByRouteKey.get(routeKey); - if (!state) { - state = { sendInFlight: { current: false }, uncertainSubmissions: { current: new Map() } }; - draftSendStateByRouteKey.set(routeKey, state); - } - return state; -} - -/** - * Starts one thread of a multiple-model send. A request that may have - * reached the server before failing is remembered by `retryKey`, so sending - * again cannot silently create a duplicate thread; its failure toast offers - * to open that thread or to allow the retry. - */ -async function startFanoutThread(input: { - readonly retryKey: string; - readonly model: string; - readonly uncertainSubmissions: Map; - readonly start: (threadId: ThreadId) => Promise>; - readonly openThread: (threadId: ThreadId) => void; -}): Promise { - const { retryKey, uncertainSubmissions } = input; - const uncertainThreadId = uncertainSubmissions.get(retryKey); - const targetThreadId = uncertainThreadId ?? newThreadId(); - let requestMayHaveStarted = false; - try { - if (uncertainThreadId) { - throw new Error( - "The previous request may have started. Open its thread to check before sending again.", - ); - } - requestMayHaveStarted = true; - const result = await input.start(targetThreadId); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - if (wasBootstrapThreadDeleted(error) || wasBootstrapThreadNotCreated(error)) { - requestMayHaveStarted = false; - } - throw error; - } - return true; - } catch (error) { - if (requestMayHaveStarted && !uncertainSubmissions.has(retryKey)) { - uncertainSubmissions.set(retryKey, targetThreadId); - } - const retainedThreadId = uncertainSubmissions.get(retryKey); - const failureToastId = toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not start ${input.model}`, - description: error instanceof Error ? error.message : "Failed to send message.", - ...(retainedThreadId - ? { - timeout: 0, - data: { - secondaryActionProps: { - children: "Allow retry", - onClick: () => { - void readLocalApi() - ?.dialogs.confirm( - "The previous request may already be running. Check its thread first. Allow another send that could create a duplicate thread?", - ) - .then( - (confirmed) => { - if ( - confirmed && - uncertainSubmissions.get(retryKey) === retainedThreadId - ) { - uncertainSubmissions.delete(retryKey); - toastManager.close(failureToastId); - } - }, - () => undefined, - ); - }, - }, - }, - actionProps: { - children: "Open thread", - onClick: () => input.openThread(retainedThreadId), - }, - } - : {}), - }), - ); - return false; - } -} - -/** - * Reverts the thread to before `message` and puts that message's text and - * attachments back in the composer draft. Returns the restored prompt, or the - * error to show; on error the draft is untouched. - */ -async function rewindToMessage(input: { - readonly message: ChatMessage; - readonly environmentId: EnvironmentId; - readonly routeThreadRef: ScopedThreadRef; - readonly threadId: ThreadId; - readonly turnCount: number; - readonly restoreFiles: boolean; - readonly checkpoint: ReturnType; - readonly draftTarget: ScopedThreadRef | DraftId; - readonly composerHasPendingAttachments: boolean; - readonly createAssetUrl: Parameters< - typeof prepareRevertedMessageAttachments - >[0]["createAssetUrl"]; - readonly revertThreadCheckpoint: (value: { - environmentId: EnvironmentId; - input: { - commandId: CommandId; - threadId: ThreadId; - turnCount: number; - restoreFiles: boolean; - checkpointId?: string; - scopeId?: string; - }; - }) => Promise>; -}): Promise< - | { readonly error: string; readonly prompt?: never } - | { readonly error: null; readonly prompt: string } -> { - const { message, environmentId, draftTarget } = input; - try { - if (input.composerHasPendingAttachments) { - throw new Error("Wait for attachments to finish preparing before rewinding."); - } - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error("The environment is not connected."); - const files = await prepareRevertedMessageAttachments({ - message, - environmentId, - httpBaseUrl: connection.httpBaseUrl, - createAssetUrl: input.createAssetUrl, - }); - const store = useComposerDraftStore.getState(); - const draft = store.getComposerDraft(draftTarget); - if ( - (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length > - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { - throw new Error("Make room for this message's attachments in the composer before rewinding."); - } - const commandId = CommandId.make(randomUUID()); - await waitForRevertedMessage(input.routeThreadRef, input.turnCount, commandId, async () => { - const result = await input.revertThreadCheckpoint({ - environmentId, - input: { - commandId, - threadId: input.threadId, - turnCount: input.turnCount, - restoreFiles: input.restoreFiles, - ...(input.checkpoint === undefined - ? {} - : { checkpointId: input.checkpoint.id, scopeId: input.checkpoint.scopeId }), - }, - }); - if (result._tag === "Failure") throw squashAtomCommandFailure(result); - }); - const currentPrompt = store.getComposerDraft(draftTarget)?.prompt ?? ""; - const restoredPrompt = recallableComposerPrompt(message.text); - const nextPrompt = - restoredPrompt.length === 0 - ? currentPrompt - : currentPrompt.length > 0 - ? `${currentPrompt}\n\n${restoredPrompt}` - : restoredPrompt; - store.setPrompt(draftTarget, nextPrompt); - const images: ComposerImageAttachment[] = []; - const restoredFiles: ComposerFileAttachment[] = []; - files.forEach((file, index) => { - const attachment = { - id: randomUUID(), - name: file.name, - mimeType: file.type, - sizeBytes: file.size, - file, - }; - if (message.attachments?.[index]?.type === "image") { - images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); - } else { - restoredFiles.push({ ...attachment, type: "file" }); - } - }); - store.addImages(draftTarget, images, { allowDuplicates: true }); - store.addFiles(draftTarget, restoredFiles, { allowDuplicates: true }); - return { error: null, prompt: nextPrompt }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Failed to revert thread state." }; - } -} - -function formatOutgoingPrompt(params: { - provider: ProviderDriverKind; - model: string | null; - models: ReadonlyArray; - effort: string | null; - text: string; -}): string { - const caps = getProviderModelCapabilities(params.models, params.model, params.provider); - const promptEffort = resolvePromptInjectedEffort(caps, params.effort); - return applyClaudePromptEffortPrefix(params.text, promptEffort); -} -const SCRIPT_TERMINAL_COLS = 120; -const SCRIPT_TERMINAL_ROWS = 30; - -function isCompactCommandMessage(message: ChatMessage): boolean { - const text = message.text.trim().toLowerCase(); - return message.role === "user" && text === "/compact" && !message.attachments?.length; -} type ChatViewProps = | { @@ -928,157 +410,11 @@ type ChatViewProps = draftId: DraftId; }; -interface TerminalLaunchContext { - threadId: ThreadId; - cwd: string; - worktreePath: string | null; -} - -function useLocalDispatchState(input: { - activeThread: Thread | undefined; - activeLatestRun: Thread["latestRun"] | null; - latestUserMessageId: MessageId | null; - phase: SessionPhase; - activePendingApproval: RuntimeRequestId | null; - activePendingUserInput: RuntimeRequestId | null; - threadError: string | null | undefined; -}) { - const [localDispatch, setLocalDispatch] = useState(null); - - const resetLocalDispatch = () => { - setLocalDispatch(null); - }; - - const serverAcknowledgedLocalDispatch = hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: input.phase, - latestRun: input.activeLatestRun, - latestUserMessageId: input.latestUserMessageId, - runtime: input.activeThread?.runtime ?? null, - hasPendingApproval: input.activePendingApproval !== null, - hasPendingUserInput: input.activePendingUserInput !== null, - threadError: input.threadError, - }); - const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; - const beginLocalDispatch = (options?: { - preparingWorktree?: boolean; - submissionIntent?: ComposerSubmissionIntent; - }) => { - const preparingWorktree = Boolean(options?.preparingWorktree); - setLocalDispatch((current) => { - const active = serverAcknowledgedLocalDispatch ? null : current; - if (active) { - const submissionIntent = options?.submissionIntent ?? active.submissionIntent; - return active.preparingWorktree === preparingWorktree && - active.submissionIntent === submissionIntent - ? active - : { ...active, preparingWorktree, submissionIntent }; - } - return createLocalDispatchSnapshot(input.activeThread, { - ...options, - latestUserMessageId: input.latestUserMessageId, - }); - }); - }; - - return { - beginLocalDispatch, - resetLocalDispatch, - localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, - isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, - isSendBusy: activeLocalDispatch !== null, - backgroundSubmissionPending: activeLocalDispatch?.submissionIntent === "background", - }; -} - -// Errors surface through two maps (draft-keyed and thread-keyed) whose entries -// can race around promotion, so each write carries its time to let the latest -// one win when they collide. -type LocalThreadErrorEntry = { - readonly message: string | null; - readonly at: number; -}; - -function chatActionErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "An error occurred."; -} - -const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; const noopHeldTurnDiff = (_turnId: RunId, _filePath?: string) => {}; const noopHeldRevert = (_targetTurnCount: number) => {}; const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; -function releaseChatTimelineAnchor( - current: T, -): T { - return current.messageId === null ? current : { ...current, messageId: null }; -} - -// The factories below build functions ChatView calls during render, or hands -// to ChatComposer, which can call them after an await. They live outside the -// component so each closure keeps only its inputs, not a whole render scope -// (see useStableCallback), while still acting on the render that made them. - -/** Records a thread error in local state, keyed by the route the making render saw. */ -function makeThreadErrorSetter(input: { - readonly routeThreadRef: ScopedThreadRef; - readonly routeThreadKey: string; - readonly draftId: DraftId | null; - readonly serverThreadRef: ScopedThreadRef | null; - readonly setLocalServerErrorsByThreadKey: ( - update: SetStateAction>, - ) => void; - readonly setLocalDraftErrorsByDraftId: ( - update: SetStateAction>, - ) => void; -}) { - return (targetThreadId: ThreadId | null, error: string | null) => { - if (!targetThreadId) return; - const nextError = sanitizeThreadErrorMessage(error); - const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; - const { routeThreadRef, serverThreadRef } = input; - if ( - serverThreadRef && - targetThreadId === routeThreadRef.threadId && - serverThreadRef.environmentId === routeThreadRef.environmentId && - serverThreadRef.threadId === targetThreadId - ) { - input.setLocalServerErrorsByThreadKey((existing) => { - if ((existing[input.routeThreadKey]?.message ?? null) === nextError) { - return existing; - } - return { ...existing, [input.routeThreadKey]: nextEntry }; - }); - return; - } - const localDraftErrorKey = input.draftId ?? targetThreadId; - input.setLocalDraftErrorsByDraftId((existing) => { - if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { - return existing; - } - return { ...existing, [localDraftErrorKey]: nextEntry }; - }); - }; -} - -/** Why the model picker should disable a model, or null when it can be chosen. */ -function makeModelDisabledReason( - context: Omit< - Parameters[0], - "nextModelSelection" - > | null, -) { - return (instanceId: ProviderInstanceId, model: string): string | null => { - if (!context) return null; - const reason = getStartedThreadModelChangeBlockReason({ - ...context, - nextModelSelection: { instanceId, model }, - }); - return reason ? `${reason.description} Start a new thread to use this model.` : null; - }; -} - export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -1090,12 +426,8 @@ export default function ChatView(props: ChatViewProps) { } = props; const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); - const hasTerminalWriteAccess = () => - readEnvironmentScope(environmentId, AuthTerminalOperateScope); - const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope); const draftId = routeKind === "draft" ? props.draftId : null; const handleNewThread = useNewThreadHandler(); - const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const routeThreadRef = scopeThreadRef(environmentId, threadId); const routeThreadKey = scopedThreadKey(routeThreadRef); const currentRouteThreadKeyRef = useRef(routeThreadKey); @@ -1105,13 +437,10 @@ export default function ChatView(props: ChatViewProps) { currentRouteThreadKeyRef.current = null; }; }, [routeThreadKey]); - const createThread = useOrchestrationCommand(threadEnvironment.create, { reportFailure: false }); - const deleteThread = useOrchestrationCommand(threadEnvironment.delete, { reportFailure: false }); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); - const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, }); @@ -1119,25 +448,15 @@ export default function ChatView(props: ChatViewProps) { reportFailure: false, }); // T3-CUSTOM(expbkt3): persist every native run under the signed-in operator. - const sendThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { - reportFailure: false, - }); + const startThreadTurn = useOperatorStartTurn(); const currentUserId = useCurrentUserId(); - const outboxIdentityKey = currentUserId ?? "anonymous"; - const startThreadTurn = (target: Parameters[0]) => - sendThreadTurn({ ...target, input: { ...target.input, outboxIdentityKey } }); + const outboxIdentityKey = useOutboxIdentityKey(); const durableOutbox = useDurableThreadOutbox( routeKind === "server" ? routeThreadRef : null, outboxIdentityKey, ); useThreadHistorySync(routeKind === "server" ? routeThreadRef : null); useThreadPresenceScope(routeKind === "server" ? routeThreadRef : null); - const resumeThreadQueue = useOrchestrationCommand(threadEnvironment.resumeThreadQueue, { - reportFailure: false, - }); - const uploadThreadFeedback = useOrchestrationCommand(threadEnvironment.uploadFeedback, { - reportFailure: false, - }); const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, @@ -1149,20 +468,9 @@ export default function ChatView(props: ChatViewProps) { label: "load earlier thread history", reportFailure: false, }); - const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, { - reportFailure: false, - }); - const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, { - reportFailure: false, - }); - const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); const { environments } = useEnvironments(); const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironment = usePrimaryEnvironment(); - const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); - const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { - reportFailure: false, - }); const environmentById = new Map( environments.map((environment) => [environment.environmentId, environment]), ); @@ -1182,48 +490,10 @@ export default function ChatView(props: ChatViewProps) { }); const serverThreadProjection = useThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; - const serverThread = (() => { - if (serverThreadShell !== null || serverProjection === null) { - return serverThreadShell; - } - const latestRun = deriveLatestThreadRun(serverProjection); - const runtime = deriveThreadRuntime(serverProjection); - const pendingRuntimeRequest = - serverProjection.runtimeRequests.findLast((request) => request.status === "pending") ?? null; - // Direct links can hydrate detail before the thread enters the shell index. - return { - ...presentThreadShell(environmentId, { - ...serverProjection.thread, - latestRunId: latestRun?.runId ?? null, - activeRunId: runtime?.activeRunId ?? null, - status: runtime?.status ?? "idle", - pendingRuntimeRequest, - latestVisibleMessage: null, - latestUserMessageAt: - serverProjection.messages.findLast((message) => message.role === "user")?.updatedAt ?? - null, - hasActionableProposedPlan: serverProjection.plans.some( - (plan) => plan.kind === "proposed_plan" && plan.status === "active", - ), - providerInstanceHistory: [ - ...new Set( - serverProjection.providerThreads - .filter((thread) => thread.appThreadId === threadId && thread.ownerNodeId === null) - .map((thread) => thread.providerInstanceId), - ), - ], - goal: - serverProjection.providerThreads.find( - (thread) => thread.id === serverProjection.thread.activeProviderThreadId, - )?.goal ?? null, - itemCount: serverProjection.turnItems.length, - visibleItemCount: serverProjection.visibleTurnItems.length, - updatedAt: serverProjection.updatedAt, - }), - latestRun, - runtime, - }; - })(); + const serverThread = + serverThreadShell !== null || serverProjection === null + ? serverThreadShell + : presentThreadShellFromProjection(environmentId, threadId, serverProjection); const promptRef = useRef(""); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; @@ -1289,12 +559,8 @@ export default function ChatView(props: ChatViewProps) { })(); const settings = useEnvironmentSettings(environmentId); const clientSettingsHydrated = useClientSettingsHydrated(); - const setStickyComposerModelSelection = useComposerDraftStore( - (store) => store.setStickyModelSelection, - ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); - const router = useRouter(); // Selects primitives: an object here is new on every router store update, which // re-renders ChatView on unrelated navigation state changes. const citationHref = useLocation({ select: (location) => location.href }); @@ -1342,35 +608,11 @@ export default function ChatView(props: ChatViewProps) { return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false; }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); - const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); - const setComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.setTerminalContexts, - ); - const setComposerDraftPreviewAnnotations = useComposerDraftStore( - (store) => store.setPreviewAnnotations, - ); - const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); - const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); - const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); - const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); - const setComposerDraftInteractionMode = useComposerDraftStore( - (store) => store.setInteractionMode, - ); - const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const getDraftSessionByLogicalProjectKey = useComposerDraftStore( - (store) => store.getDraftSessionByLogicalProjectKey, - ); - const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); - const setLogicalProjectDraftThreadId = useComposerDraftStore( - (store) => store.setLogicalProjectDraftThreadId, - ); const composerImagesRef = useRef([]); const composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const branchToolbarRef = useRef(null); - const pasteAsTextShortcutUntilRef = useRef(0); const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); @@ -1389,7 +631,6 @@ export default function ChatView(props: ChatViewProps) { return inserted; }; const citeAssistantTextStable = useStableCallback(citeAssistantText); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); // Set by "Work locally": the draft whose restored message should be resent @@ -1405,10 +646,6 @@ export default function ChatView(props: ChatViewProps) { const feedbackUploading = feedbackSubmissions.some( (submission) => submission.status === "uploading", ); - const optimisticUserMessagesRef = useRef(optimisticUserMessages); - useLayoutEffect(() => { - optimisticUserMessagesRef.current = optimisticUserMessages; - }); const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< Record >({}); @@ -1443,9 +680,6 @@ export default function ChatView(props: ChatViewProps) { const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = useState(null); - const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< - Record - >({}); // Overrides chosen before a server thread's first turn; they belong to that thread only. const [pendingServerThreadEnvModeEntry, setPendingServerThreadEnvModeEntry] = useState<{ readonly threadId: ThreadId; @@ -1459,11 +693,6 @@ export default function ChatView(props: ChatViewProps) { pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - {}, - LastInvokedScriptByProjectSchema, - ); const legendListRef = useRef(null); const getTimelineScrollableNode = () => legendListRef.current?.getScrollableNode() ?? null; const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode); @@ -1476,21 +705,9 @@ export default function ChatView(props: ChatViewProps) { const composerRestingRef = useRef(false); const composerMountedRef = useRef(true); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); - const isAtEndRef = useRef(true); - const isTimelineAtLogicalEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current; - const isTimelineAtLogicalEndStable = useStableCallback(isTimelineAtLogicalEnd); const [timelineOverflows, setTimelineOverflows] = useState(false); - const attachmentPreviewHandoffByMessageIdRef = useRef>({}); - const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const fanoutSelectionsAtom = draftFanoutSelectionsAtom(routeThreadKey); - const { sendInFlight: sendInFlightRef, uncertainSubmissions: uncertainMultipleSubmissionsRef } = - draftSendState(routeThreadKey); - const [resumingThreadKeys, setResumingThreadKeys] = useState>( - () => new Set(), - ); - const isResuming = resumingThreadKeys.has(routeThreadKey); - const composerSendGenerationRef = useRef(0); + const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey); const multipleModelSelections = useAtomValue(fanoutSelectionsAtom); const setMultipleModelSelections = ( selections: SetStateAction | null>, @@ -1500,39 +717,12 @@ export default function ChatView(props: ChatViewProps) { ); }; const setMultipleModelSelectionsStable = useStableCallback(setMultipleModelSelections); - const readMultipleModelSelections = () => appAtomRegistry.get(fanoutSelectionsAtom); - const environmentUnavailableSendToastSlotRef = useRef(0); const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); - const openTerminalThreadKeys = useTerminalUiStateStore( - useShallow((state) => - Object.entries(state.terminalUiStateByThreadKey).flatMap( - ([nextThreadKey, nextTerminalUiState]) => - nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], - ), - ), - ); - const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); - const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); - const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); - const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); - const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); - const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); - const serverThreadRefs = useThreadRefs(); - const serverThreadKeys = serverThreadRefs.map(scopedThreadKey); - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftThreadKeys = Object.values(draftThreadsByThreadKey).map((draftThread) => - scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), - ); - const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); - const mountedTerminalThreadRefs = mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -1708,16 +898,11 @@ export default function ChatView(props: ChatViewProps) { return labels; })(); const activeThreadRef = useActiveThreadRef(activeThread); - const terminalActions = useThreadTerminalActions(activeThreadRef); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser( activeThreadRef?.environmentId ?? null, ); const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; - const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { - container: workspaceLayoutElement, - widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, - }); const activeThreadShell = serverThread; const timelineThreadError = serverRuntime?.status === "failed" && @@ -1793,13 +978,6 @@ export default function ChatView(props: ChatViewProps) { const rightPanelOpen = rightPanelState.isOpen; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); - const activeTerminalDrawerPresence = usePanelPresence( - Boolean(activeThreadKey && terminalUiState.terminalOpen), - true, - panelAnimationsActive, - activeThreadKey, - panelAnimationDurationMs, - ); const rightPanelPresenceValue = { activeSurface: activeRightPanelSurface, surfaces: rightPanelState.surfaces, @@ -1866,25 +1044,6 @@ export default function ChatView(props: ChatViewProps) { } }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]); - const existingOpenTerminalThreadKeys = (() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - })(); - const reconciledMountedTerminalThreadKeys = reconcileMountedTerminalThreadIds({ - currentThreadIds: mountedTerminalThreadKeys, - openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: activeTerminalDrawerPresence.present, - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - if ( - reconciledMountedTerminalThreadKeys.length !== mountedTerminalThreadKeys.length || - reconciledMountedTerminalThreadKeys.some( - (threadKey, index) => threadKey !== mountedTerminalThreadKeys[index], - ) - ) { - setMountedTerminalThreadKeys(reconciledMountedTerminalThreadKeys); - } const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime); const activePlan = deriveActivePlanState(serverProjection, activeActivityRun?.runId); const activeActivityRunSettled = isLatestRunSettled(activeActivityRun, activeRuntime); @@ -1923,110 +1082,8 @@ export default function ChatView(props: ChatViewProps) { activeProject ?? undefined, ); const activeProjectScripts = activeProject ? resolveProjectScripts(settings, activeProject) : []; - const activeProjectClone = useProjectClone(activeProjectRef); - const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { - reportFailure: false, - }); - const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { - reportFailure: false, - }); - const removeClonedProject = useRemoveClonedProject(); - const runProjectCloneAction = async ( - title: string, - action: () => Promise>, - ): Promise => { - const result = await action(); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }; - const runProjectCloneActionStable = useStableCallback(runProjectCloneAction); - const activeProjectFileCopy = useProjectFileCopy(activeProjectRef); - const projectCloneSendBlockReason = - activeProjectClone?.phase === "running" - ? "Cloning repository" - : activeProjectClone !== null && activeProjectClone.phase !== "done" - ? "Repository not cloned" - : activeProjectFileCopy?.phase === "copying" - ? "Copying files" - : null; - const projectCloneBannerItem = (() => { - if (activeProjectFileCopy?.phase === "failed" && activeProjectRef) { - return { - id: `project-file-copy:${activeProjectRef.projectId}`, - variant: "error", - icon: , - title: activeProjectFileCopy.title, - description: activeProjectFileCopy.failures.join("\n"), - onDismiss: () => dismissProjectFileCopy(activeProjectRef), - }; - } - if (!activeProjectClone || !activeProjectRef || activeProjectClone.phase === "done") { - return null; - } - const name = projectCloneDisplayName(activeProjectClone); - const { environmentId, projectId } = activeProjectRef; - if (activeProjectClone.phase === "running") { - return { - id: `project-clone:${projectId}`, - variant: "info", - priority: "activity", - icon: , - title: `Cloning ${name}`, - description: projectCloneProgressSummary(activeProjectClone), - actions: ( - - ), - }; - } - const cancelled = activeProjectClone.phase === "cancelled"; - return { - id: `project-clone:${projectId}`, - variant: cancelled ? "warning" : "error", - icon: , - title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, - description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, - actions: ( - <> - - - - ), - }; - })() as ComposerBannerStackItem | null; + const { projectCloneBannerItem, projectCloneSendBlockReason } = + useProjectCloneBanner(activeProjectRef); const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); @@ -2080,7 +1137,6 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); - const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; useEffect(() => { if (!activeThreadRef || !activeProjectRef) return; @@ -2088,35 +1144,8 @@ export default function ChatView(props: ChatViewProps) { }, [activeProjectRef, activeThreadRef]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); - const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = - activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; - const activeReconnectingEnvironmentId = - activeEnvironmentConnectionPhase === "connecting" || - activeEnvironmentConnectionPhase === "reconnecting" - ? (activeEnvironment?.environmentId ?? null) - : null; - const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = - useState(null); - // Each reconnect attempt gets its own grace period, including a later one to - // the same environment. - const [graceReconnectingEnvironmentId, setGraceReconnectingEnvironmentId] = useState( - activeReconnectingEnvironmentId, - ); - if (graceReconnectingEnvironmentId !== activeReconnectingEnvironmentId) { - setGraceReconnectingEnvironmentId(activeReconnectingEnvironmentId); - setReconnectWarningGraceElapsedEnvironmentId(null); - } - const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( - activeReconnectingEnvironmentId, - reconnectWarningGraceElapsedEnvironmentId, - ); - useEffect(() => { - if (activeReconnectingEnvironmentId === null) return; - return scheduleEnvironmentReconnectWarning(() => - setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), - ); - }, [activeReconnectingEnvironmentId]); + activeEnvironment !== null && activeEnvironment.connection.phase !== "connected"; const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = (() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { @@ -2129,54 +1158,6 @@ export default function ChatView(props: ChatViewProps) { connection: activeEnvironment.connection, }; })() as EnvironmentUnavailableState | null; - const handleReconnectActiveEnvironment = async (environmentId: EnvironmentId) => { - const result = await retryEnvironment(environmentId); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not reconnect environment", - description: error instanceof Error ? error.message : "Failed to reconnect.", - }), - ); - } - }; - const handleReconnectActiveEnvironmentStable = useStableCallback( - handleReconnectActiveEnvironment, - ); - const disconnectDelayElapsed = useEnvironmentDisconnectDelay( - activeEnvironmentUnavailable ? activeEnvironment.environmentId : null, - ); - const canDisconnectActiveEnvironment = - disconnectDelayElapsed && - activeEnvironment !== null && - activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && - !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); - const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false); - const handleDisconnectActiveEnvironment = async (environmentId: EnvironmentId) => { - setDisconnectingEnvironment(true); - const result = await setEnvironmentEnabled({ environmentId, enabled: false }); - setDisconnectingEnvironment(false); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not disconnect server", - description: error instanceof Error ? error.message : "Failed to disconnect.", - }), - ); - } - return; - } - void navigate({ to: "/", replace: true }); - }; - const handleDisconnectActiveEnvironmentStable = useStableCallback( - handleDisconnectActiveEnvironment, - ); - const { openScratchProject } = useScratchProject(); const activeProjectIsScratch = activeProject !== null && isScratchProject( @@ -2197,91 +1178,18 @@ export default function ChatView(props: ChatViewProps) { activeEnvironment: activeEnvironmentOption, canPickEnvironment: hasMultipleEnvironments, }); - const openPullRequestDialog = (reference?: string) => { - if (!canCheckoutPullRequestIntoThread) { - return; - } - if (activeThreadKey === null) return; - openPullRequestThreadDialog(activeThreadKey, reference); - }; - const openPullRequestDialogStable = useStableCallback(openPullRequestDialog); - const openOrReuseProjectDraftThread = async (input: { - branch: string; - worktreePath: string | null; - envMode: DraftThreadEnvMode; - }) => { - if (!activeProject) { - throw new Error("No active project is available for this pull request."); - } - const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); - const logicalProjectKey = deriveLogicalProjectKeyFromSettings( - activeProject, + const { handlePreparedPullRequestThreadStable, openPullRequestDialogStable } = + usePullRequestDraftThread({ + routeKind, + draftId, + settings, projectGroupingSettings, - ); - const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); - if (storedDraftSession) { - setDraftThreadContext(storedDraftSession.draftId, input); - setLogicalProjectDraftThreadId( - logicalProjectKey, - activeProjectRef, - storedDraftSession.draftId, - { - threadId: storedDraftSession.threadId, - ...input, - }, - ); - if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(storedDraftSession.draftId), - }); - } - return storedDraftSession.threadId; - } - - const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; - if (!isServerThread && activeDraftSession?.logicalProjectKey === logicalProjectKey && draftId) { - setDraftThreadContext(draftId, input); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { - threadId: activeDraftSession.threadId, - createdAt: activeDraftSession.createdAt, - runtimeMode: activeDraftSession.runtimeMode, - interactionMode: activeDraftSession.interactionMode, - ...input, - }); - return activeDraftSession.threadId; - } - - const nextDraftId = newDraftId(); - const nextThreadId = newThreadId(); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { - threadId: nextThreadId, - createdAt: new Date().toISOString(), - runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings - .defaultRuntimeMode, - interactionMode: DEFAULT_INTERACTION_MODE, - ...input, - }); - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(nextDraftId), - }); - return nextThreadId; - }; - - const handlePreparedPullRequestThread = async (input: { - branch: string; - worktreePath: string | null; - }) => { - await openOrReuseProjectDraftThread({ - branch: input.branch, - worktreePath: input.worktreePath, - envMode: input.worktreePath ? "worktree" : "local", + activeProject, + activeThreadKey, + isServerThread, + canCheckoutPullRequestIntoThread, }); - }; - const handlePreparedPullRequestThreadStable = useStableCallback(handlePreparedPullRequestThread); - const serverConfig = activeThread ? (activeEnvironment?.serverConfig ?? null) : (primaryEnvironment?.serverConfig ?? null); @@ -2345,176 +1253,16 @@ export default function ChatView(props: ChatViewProps) { return environment ? [environment] : []; }) : EMPTY_AUTO_UPDATE_ENVIRONMENTS; - const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); - const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); - const versionMismatchDismissKey = - versionMismatch && activeThread - ? buildVersionMismatchDismissalKey(activeThread.environmentId, versionMismatch) - : null; - const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState( - null, - ); - const versionMismatchDismissed = - versionMismatchDismissKey === dismissedVersionMismatchKey || - isVersionMismatchDismissed(versionMismatchDismissKey); - const showVersionMismatchBanner = - versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; - const hasMultipleRegisteredEnvironments = environments.length > 1; - const versionMismatchServerLabel = - hasMultipleRegisteredEnvironments && activeThread - ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` - : "server"; - const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; - const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); - const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation; - const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); - const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); - const serverUpdateState = useAtomValue( - serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), - ); - const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState< - typeof serverUpdateState | null - >(null); - const serverUpdateFailureDismissed = - serverUpdateState === dismissedServerUpdateState || - isServerUpdateFailureDismissed(serverUpdateState); - const systemComposerBannerItems = (() => { - const items: ComposerBannerStackItem[] = []; - const updateRunning = serverUpdateState.status === "running"; - const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; - const disconnectAction = - canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? ( - - ) : undefined; - const environmentReconnecting = - unavailableConnection !== null && - (unavailableConnection.phase === "connecting" || - unavailableConnection.phase === "reconnecting"); - const suppressUnavailableBanner = - environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); - if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: unavailableConnection.phase === "error" ? "error" : "warning", - icon: , - title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, - description: activeEnvironment ? ( - - ) : undefined, - actions: ( - <> - {!environmentReconnecting ? ( - - ) : null} - {disconnectAction} - - ), - }); - } - if ( - !automaticEnvironment && - serverUpdateEnvironmentId && - (serverUpdateState.status === "idle" - ? showVersionMismatchBanner - : !serverUpdateFailureDismissed) - ) { - const updateInProgress = serverUpdateState.status === "running"; - const updateFailed = serverUpdateState.status === "failed"; - items.push({ - id: `server-version:${serverUpdateEnvironmentId}`, - variant: updateFailed ? "error" : "default", - priority: updateInProgress ? "urgent" : "notice", - icon: , - title: - updateInProgress || updateFailed ? ( - `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}` - ) : versionMismatch ? ( - - - Server update available - - } - /> - - {versionMismatchServerLabel} {versionMismatch.serverVersion}{" "} - {versionMismatch.clientVersion} - - - ) : ( - "Server update available" - ), - description: - serverUpdateState.status === "failed" - ? serverUpdateState.message - : serverUpdateState.status === "running" - ? serverUpdateStageLabel(serverUpdateState.stage) - : versionMismatchSelfUpdate !== null && - (versionMismatchSelfUpdate !== "desktop-managed" || - !versionMismatchDesktopAppUpdate) - ? serverUpdateGuidance(versionMismatchSelfUpdate) - : undefined, - actions: updateInProgress ? ( - disconnectAction - ) : !versionMismatch || - (versionMismatchSelfUpdate === "desktop-managed" && - !versionMismatchDesktopAppUpdate) ? undefined : ( - - ), - ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) - ? {} - : { - dismissLabel: "Dismiss update notice", - onDismiss: () => { - if (updateFailed) { - dismissServerUpdateFailure(serverUpdateState); - setDismissedServerUpdateState(serverUpdateState); - } - dismissVersionMismatch(versionMismatchDismissKey); - setDismissedVersionMismatchKey(versionMismatchDismissKey); - }, - }), - }); - } - if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); - return items; - })() as ComposerBannerStackItem[]; + const systemComposerBannerItems = useSystemComposerBanners({ + activeEnvironment, + activeEnvironmentUnavailable, + activeEnvironmentUnavailableState, + activeThreadEnvironmentId: activeThread?.environmentId ?? null, + serverConfig, + hasMultipleRegisteredEnvironments: environments.length > 1, + automaticEnvironment, + autoUpdateEnvironments, + }); const providerInstanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings( deriveProviderInstanceEntries(providerStatuses), @@ -2595,13 +1343,6 @@ export default function ChatView(props: ChatViewProps) { composerRef, setThreadError, }); - // A send awaits uploads before it declines, so it reads the approvals pending by then. - const pendingApprovalsRef = useRef(pendingApprovals); - const respondingRequestIdsRef = useRef(respondingRequestIds); - useLayoutEffect(() => { - pendingApprovalsRef.current = pendingApprovals; - respondingRequestIdsRef.current = respondingRequestIds; - }, [pendingApprovals, respondingRequestIds]); const activeProposedPlan = (() => { if (!latestRunSettled) { return null; @@ -2708,31 +1449,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadLiveTokenUsage, activeProviderThread, ); - const pendingBackgroundTasks = (() => { - if (serverProjection === null || serverProjection === undefined) { - return []; - } - const sessionError = - serverProjection.providerSessions.findLast( - (session) => session.providerInstanceId === serverProjection.thread.providerInstanceId, - )?.lastError ?? null; - const latestRun = - usageLimitRunPresentedAsLatest( - serverProjection.runs, - serverProjection.turnItems, - sessionError, - ) ?? latestUnheldRun(serverProjection.runs); - return [ - ...derivePendingBackgroundWork({ - latestRun, - providerThreads: serverProjection.providerThreads, - turnItems: serverProjection.turnItems, - activeProviderThreadId: serverProjection.thread.activeProviderThreadId, - runs: serverProjection.runs, - pullRequests: serverProjection.thread.pullRequests, - }), - ]; - })(); + const pendingBackgroundTasks = deriveThreadPendingBackgroundTasks(serverProjection); const activeWorkStartedAt = deriveActiveWorkStartedAt(activeActivityRun, activeRuntime, localDispatchStartedAt) ?? runlessWorkStartedAt; @@ -2740,69 +1457,6 @@ export default function ChatView(props: ChatViewProps) { const sendQueuesBehindSetup = isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting"); const [setupQueueSendThreadKey, setSetupQueueSendThreadKey] = useState(null); - useEffect(() => { - attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; - }, [attachmentPreviewHandoffByMessageId]); - const clearAttachmentPreviewHandoff = ( - messageId: MessageId, - previewUrls?: ReadonlyArray, - ) => { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - const currentPreviewUrls = - previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) { - return existing; - } - const next = { ...existing }; - delete next[messageId]; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - for (const previewUrl of currentPreviewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - }; - const clearAttachmentPreviewHandoffStable = useStableCallback(clearAttachmentPreviewHandoff); - const clearAttachmentPreviewHandoffs = () => { - attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; - for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { - for (const previewUrl of previewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - } - attachmentPreviewHandoffByMessageIdRef.current = {}; - setAttachmentPreviewHandoffByMessageId({}); - }; - const clearAttachmentPreviewHandoffsStable = useStableCallback(clearAttachmentPreviewHandoffs); - useEffect(() => { - return () => { - clearAttachmentPreviewHandoffsStable(); - for (const message of optimisticUserMessagesRef.current) { - revokeUserMessagePreviewUrls(message); - } - }; - }, [clearAttachmentPreviewHandoffsStable]); - const handoffAttachmentPreviews = (messageId: MessageId, previewUrls: string[]) => { - if (previewUrls.length === 0) return; - - const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - const nextPreviewUrlSet = new Set(previewUrls); - for (const previewUrl of previousPreviewUrls) { - if (!nextPreviewUrlSet.has(previewUrl)) { - revokeBlobPreviewUrl(previewUrl); - } - } - setAttachmentPreviewHandoffByMessageId((existing) => { - const next = { - ...existing, - [messageId]: previewUrls, - }; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - }; - const handoffAttachmentPreviewsStable = useStableCallback(handoffAttachmentPreviews); const downloadFileAttachment = async (attachment: ChatFileAttachment) => { const connection = readPreparedConnection(environmentId); if (!connection) { @@ -2837,132 +1491,12 @@ export default function ChatView(props: ChatViewProps) { } }; const openFileAttachmentStable = useStableCallback(openFileAttachment); - const serverAttachmentResources = selectHandoffImageResources( - Object.keys(attachmentPreviewHandoffByMessageId).length === 0 - ? undefined - : serverVisibleTurnItems.flatMap(({ item }) => - item.type === "user_message" - ? [{ id: item.messageId, role: "user" as const, attachments: item.attachments }] - : [], - ), - attachmentPreviewHandoffByMessageId, - ); - const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); - const serverAttachmentUrlById = new Map( - serverAttachmentResources.flatMap((resource, index) => { - const url = serverAttachmentUrls[index]; - return url ? [[resource.attachmentId, url] as const] : []; - }), - ); - useEffect(() => { - if (typeof Image === "undefined" || serverVisibleTurnItems.length === 0) { - return; - } - - const cleanups: Array<() => void> = []; - const userMessagesById = new Map( - serverVisibleTurnItems.flatMap((row) => - row.item.type === "user_message" ? [[String(row.item.messageId), row.item] as const] : [], - ), - ); - - for (const [messageId, handoffPreviewUrls] of Object.entries( - attachmentPreviewHandoffByMessageId, - )) { - if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { - continue; - } - - const serverMessage = userMessagesById.get(messageId); - if (serverMessage === undefined || serverMessage.attachments.length === 0) { - continue; - } - - const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => - attachment.type === "image" - ? [serverAttachmentUrlById.get(attachment.id)].filter( - (previewUrl): previewUrl is string => previewUrl !== undefined, - ) - : [], - ); - if ( - serverPreviewUrls.length === 0 || - serverPreviewUrls.length !== handoffPreviewUrls.length || - serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) - ) { - continue; - } - - attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true; - - let cancelled = false; - const imageInstances: HTMLImageElement[] = []; - - const preloadServerPreviews = Promise.all( - serverPreviewUrls.map( - (previewUrl) => - new Promise((resolve, reject) => { - const image = new Image(); - imageInstances.push(image); - const handleLoad = () => resolve(); - const handleError = () => - reject(new Error(`Failed to load server preview for ${messageId}.`)); - image.addEventListener("load", handleLoad, { once: true }); - image.addEventListener("error", handleError, { once: true }); - image.src = previewUrl; - }), - ), - ); - - void preloadServerPreviews - .then(() => { - if (cancelled) { - return; - } - clearAttachmentPreviewHandoffStable(messageId as MessageId, handoffPreviewUrls); - }) - .catch(() => { - if (!cancelled) { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - } - }); - - cleanups.push(() => { - cancelled = true; - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - for (const image of imageInstances) { - image.src = ""; - } - }); - } - - return () => { - for (const cleanup of cleanups) { - cleanup(); - } - }; - }, [ - attachmentPreviewHandoffByMessageId, - clearAttachmentPreviewHandoffStable, - serverAttachmentUrlById, - serverVisibleTurnItems, - ]); - const timelineAttachmentUrlById = (() => { - const urls = new Map(serverAttachmentUrlById); - for (const row of serverVisibleTurnItems) { - if (row.item.type !== "user_message") continue; - const handoffUrls = attachmentPreviewHandoffByMessageId[row.item.messageId]; - if (handoffUrls === undefined) continue; - let imageIndex = 0; - for (const attachment of row.item.attachments) { - if (attachment.type !== "image") continue; - const handoffUrl = handoffUrls[imageIndex]; - imageIndex += 1; - if (handoffUrl !== undefined) urls.set(attachment.id, handoffUrl); - } - } - return urls; - })(); + const { handoffAttachmentPreviewsStable, timelineAttachmentUrlById } = + useAttachmentPreviewHandoff({ + environmentId, + serverVisibleTurnItems, + optimisticUserMessages, + }); const anchoredTimelineMessages = feedbackSubmissions.flatMap((submission) => submission.status === "interrupted" ? [] @@ -3053,70 +1587,16 @@ export default function ChatView(props: ChatViewProps) { panelAnimationDurationMs, ); const turnDiffSummaries = useThreadCheckpointSummaries(routeThreadDetailRef); - const availableRevertTurnCounts = (() => { - const available = new Set(); - const providerThread = serverProjection?.providerThreads.find( - (candidate) => candidate.id === serverProjection.thread.activeProviderThreadId, - ); - if ( - !providerThread?.providerSessionId || - providerThread.providerInstanceId !== serverProjection?.thread.modelSelection.instanceId - ) - return available; - available.add(0); - const attemptsById = new Map(serverProjection.attempts.map((attempt) => [attempt.id, attempt])); - const providerTurnsById = new Map< - OrchestrationV2ProviderTurn["id"], - OrchestrationV2ProviderTurn - >(); - const latestTurnByAttempt = new Map(); - for (const turn of serverProjection.providerTurns) { - providerTurnsById.set(turn.id, turn); - if (turn.runAttemptId === null) continue; - const latest = latestTurnByAttempt.get(turn.runAttemptId); - if (latest === undefined || turn.ordinal > latest.ordinal) { - latestTurnByAttempt.set(turn.runAttemptId, turn); - } - } - for (const run of serverProjection.runs) { - if (run.activeAttemptId === null) continue; - const attempt = attemptsById.get(run.activeAttemptId); - const providerTurn = - latestTurnByAttempt.get(run.activeAttemptId) ?? - (attempt?.providerTurnId == null - ? undefined - : providerTurnsById.get(attempt.providerTurnId)); - // A ready file checkpoint alone cannot restore the provider conversation. - if (providerTurn?.providerThreadId === providerThread.id) available.add(run.ordinal); - } - return available; - })(); + const availableRevertTurnCounts = deriveAvailableRevertTurnCounts(serverProjection); // A paged history window cannot identify the first loaded run as the thread start. const threadStartCheckpointReady = !serverThreadHistory.hasMoreHistory && findRevertCheckpoint(serverProjection?.checkpoints ?? [], 0) !== undefined; - /** - * File tools are explicit; completed commands are included because a shell - * command can mutate the workspace without reporting the paths it touched. - */ - const workspaceMutationId = (() => { - let itemId: string | null = null; - for (let index = serverVisibleTurnItems.length - 1; index >= 0; index -= 1) { - const item = serverVisibleTurnItems[index]?.item; - if (!item) continue; - if (item.type !== "command_execution" && item.type !== "file_change") continue; - if (item.status === "pending" || item.status === "running" || item.status === "waiting") { - continue; - } - itemId = item.id; - break; - } - const latestCheckpointCompletedAt = turnDiffSummaries.at(-1)?.completedAt ?? null; - return itemId === null && latestCheckpointCompletedAt === null - ? null - : JSON.stringify([itemId, latestCheckpointCompletedAt]); - })(); + const workspaceMutationId = deriveWorkspaceMutationId( + serverVisibleTurnItems, + turnDiffSummaries.at(-1)?.completedAt ?? null, + ); const gitCwd = activeProject ? projectScriptCwd({ @@ -3306,212 +1786,33 @@ export default function ChatView(props: ChatViewProps) { "terminal.close", terminalShortcutLabelOptions, ); - const onToggleDiff = () => { - if (!isServerThread) { - return; - } - if (!diffOpen) { - onDiffPanelOpen?.(); - } - if (activeThreadRef) { - useRightPanelStore.getState().toggle(activeThreadRef, "diff"); - } - }; - const onToggleDiffStable = useStableCallback(onToggleDiff); - const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; - const loadBalancingCandidates = needsLoadBalancing - ? logicalProjectEnvironments - .filter((candidate) => { - const environment = environmentById.get(candidate.environmentId); - return ( - environment?.connection.phase === "connected" && - (loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 && - referencedThreadContexts.every( - (record) => record.environmentId === candidate.environmentId, - ) && - providersByEnvironment - .get(candidate.environmentId) - ?.some( - (provider) => - (activeProviderInstanceId === null || - provider.instanceId === activeProviderInstanceId) && - provider.driver === selectedProvider && - (environment.serverConfig?.settings - ? resolveProjectProviderInstanceEnabled( - environment.serverConfig.settings, - candidate.projectId, - provider.instanceId, - ) - : provider.enabled) && - provider.installed && - provider.status !== "error" && - provider.auth.status !== "unauthenticated" && - provider.availability !== "unavailable", - ) - ); - }) - .map((candidate) => candidate.environmentId) - : []; - const loadBalancing = useLoadBalancedEnvironment( - loadBalancingCandidates, - loadBalancingSettings.loadBalancingWeights, - ); - useEffect(() => { - if (!needsLoadBalancing || loadBalancing.pending || !draftId || sendInFlightRef.current) return; - const target = logicalProjectEnvironments.find( - (environment) => environment.environmentId === loadBalancing.environmentId, - ); - if (!target?.projectId) return; - setDraftThreadContext(draftId, { - projectRef: scopeProjectRef(target.environmentId, target.projectId), - environmentSelection: "auto", - loadBalancedEnvironmentId: target.environmentId, - }); - }, [ + const { needsLoadBalancing, - loadBalancing.pending, - loadBalancing.environmentId, + loadBalancing, + onAutoEnvironmentStable, + autoEnvironmentLabel, + isEnvironmentChanging, + readPendingEnvironmentChange, + onEnvironmentChangeStable, + } = useDraftEnvironmentRouting({ draftId, + draftThread, + envLocked, + automaticEnvironment, logicalProjectEnvironments, + environmentById, + providersByEnvironment, + loadBalancingSettings, + referencedThreadContexts, + activeProviderInstanceId, + selectedProvider, + composerHasAttachments, + activeProjectKey, + activeProjectIsScratch, + projectGroupingSettings, sendInFlightRef, - setDraftThreadContext, - ]); - const onAutoEnvironment = () => { - if (envLocked || !draftId) return; - if (composerHasAttachments) { - toastManager.add({ - type: "warning", - id: "load-balancing-attachments", - title: "Keep attachments on this machine", - description: - "Remove attachments before choosing automatic routing, then attach them on the selected machine.", - }); - return; - } - loadBalancing.refresh( - logicalProjectEnvironments.map((environment) => environment.environmentId), - ); - setDraftThreadContext(draftId, { - environmentSelection: "auto", - loadBalancedEnvironmentId: null, - branch: null, - worktreePath: null, - }); - }; - const onAutoEnvironmentStable = useStableCallback(onAutoEnvironment); - const autoEnvironmentLabel = automaticEnvironment - ? draftThread?.loadBalancedEnvironmentId - ? "Auto balance" - : loadBalancing.pending - ? "Checking machines…" - : loadBalancing.failed - ? "Auto balance unavailable" - : "Auto balance" - : undefined; - - // A change in flight belongs to the draft and project it started on. - const environmentChangeScope = `${draftId ?? ""}:${activeProjectKey ?? ""}`; - const environmentChangeRef = useRef<{ - readonly environmentId: EnvironmentId; - readonly scope: string; - } | null>(null); - const environmentChangeScopeRef = useRef(""); - useLayoutEffect(() => { - environmentChangeScopeRef.current = environmentChangeScope; }); - // Leaving the scope, or unmounting, abandons a change still in flight. - useLayoutEffect( - () => () => { - if (environmentChangeRef.current?.scope === environmentChangeScope) { - environmentChangeRef.current = null; - } - }, - [environmentChangeScope], - ); - const readPendingEnvironmentChange = () => { - const pending = environmentChangeRef.current; - return pending !== null && pending.scope === environmentChangeScopeRef.current ? pending : null; - }; - // The busy state belongs to the request that set it, so an older request settling late - // (abandoned, or superseded by a newer switch) can never clear a newer one's. - const [busyEnvironmentChange, setBusyEnvironmentChange] = useState<{ - readonly environmentId: EnvironmentId; - readonly scope: string; - } | null>(null); - const isEnvironmentChanging = busyEnvironmentChange?.scope === environmentChangeScope; - - const onEnvironmentChange = (nextEnvironmentId: EnvironmentId) => { - if (envLocked || !draftId || sendInFlightRef.current) return; - const originalDraft = getDraftSession(draftId); - if (!originalDraft || originalDraft.promotedTo) return; - const target = logicalProjectEnvironments.find( - (env) => env.environmentId === nextEnvironmentId, - ); - if (!target) return; - const request = { environmentId: target.environmentId, scope: environmentChangeScope }; - environmentChangeRef.current = request; - setBusyEnvironmentChange(null); - const retarget = (project: (typeof allProjects)[number]) => { - const currentDraft = getDraftSession(draftId); - if ( - environmentChangeRef.current !== request || - environmentChangeScopeRef.current !== request.scope || - sendInFlightRef.current || - !currentDraft || - currentDraft.promotedTo || - currentDraft.environmentId !== originalDraft.environmentId || - currentDraft.projectId !== originalDraft.projectId - ) - return; - const projectRef = scopeProjectRef(target.environmentId, project.id); - if (activeProjectIsScratch) { - setLogicalProjectDraftThreadId( - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), - projectRef, - draftId, - { environmentSelection: "manual", loadBalancedEnvironmentId: null }, - ); - } else { - setDraftThreadContext(draftId, { - projectRef, - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); - } - }; - const finish = () => { - if (environmentChangeRef.current === request) environmentChangeRef.current = null; - setBusyEnvironmentChange((busy) => (busy === request ? null : busy)); - }; - if (target.projectId !== null) { - const project = allProjects.find( - (project) => - project.environmentId === target.environmentId && project.id === target.projectId, - ); - if (project) retarget(project); - finish(); - return; - } - setBusyEnvironmentChange(request); - void openScratchProject(target.environmentId, "Could not switch machine") - .then((project) => { - if (project) retarget(project); - }) - .finally(finish); - }; - const onEnvironmentChangeStable = useStableCallback(onEnvironmentChange); - - const activeTerminalGroup = - terminalUiState.terminalGroups.find( - (group) => group.id === terminalUiState.activeTerminalGroupId, - ) ?? - terminalUiState.terminalGroups.find((group) => - group.terminalIds.includes(terminalUiState.activeTerminalId), - ) ?? - null; - const hasReachedSplitLimit = - (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; const focusComposer = () => { composerRef.current?.focusAtEnd(); @@ -3583,199 +1884,38 @@ export default function ChatView(props: ChatViewProps) { }; const sendAppMessageStable = useStableCallback(sendAppMessage); const queuedRunsControlRef = useRef(null); - const addTerminalContextToDraft = (selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }; - const addTerminalContextToDraftStable = useStableCallback(addTerminalContextToDraft); - const setTerminalOpen = (open: boolean) => { - if (!activeThreadRef) return; - storeSetTerminalOpen(activeThreadRef, open); - }; - const setTerminalOpenStable = useStableCallback(setTerminalOpen); - const toggleTerminalVisibility = () => { - if (!activeThreadRef) return; - const nextOpen = !terminalUiState.terminalOpen; - if ( - nextOpen && - !readEnvironmentScope(environmentId, AuthTerminalReadScope) && - !hasTerminalWriteAccess() - ) - return; - if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) { - // Server-created setup terminals exist before this client's drawer mounts. - // Reuse them before allocating a shell from the still-empty UI state. - const existingTerminalId = - runningTerminalIds.find((id) => !panelTerminalIds.has(id)) ?? - activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id)); - if (existingTerminalId !== undefined) { - storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true }); - return; - } - void terminalActions.launchTerminal({ - place: (terminalId) => storeEnsureTerminal(activeThreadRef, terminalId, { open: true }), - })?.result; - return; - } - setTerminalOpen(nextOpen); - }; - const toggleTerminalVisibilityStable = useStableCallback(toggleTerminalVisibility); - const splitTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { - if (!activeThreadRef || hasReachedSplitLimit) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - direction === "vertical" - ? storeSplitTerminalVertical(activeThreadRef, terminalId) - : storeSplitTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const splitTerminalStable = useStableCallback(splitTerminal); - const createNewTerminal = () => { - if (!activeThreadRef) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => storeNewTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const createNewTerminalStable = useStableCallback(createNewTerminal); - const closeTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess() || !activeThreadRef) return; - terminalActions.closeTerminalSession(terminalId); - storeCloseTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const runProjectScript = async ( - script: ProjectScript, - options?: { - cwd?: string; - env?: Record; - worktreePath?: string | null; - preferNewTerminal?: boolean; - rememberAsLastInvoked?: boolean; - shellHistory?: "preserve" | "suppress"; - }, - ) => { - if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return; - if (options?.rememberAsLastInvoked !== false) { - setLastInvokedScriptByProjectId((current) => { - if (current[activeProject.id] === script.id) return current; - return { ...current, [activeProject.id]: script.id }; - }); - } - const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; - const reusableTerminalId = - options?.preferNewTerminal || - !canReuseTerminal || - !readEnvironmentScope(environmentId, AuthTerminalReadScope) - ? null - : findReusableProjectScriptTerminal({ - // Server sessions cover drawer terminals this client has not opened yet, such as a - // worktree setup shell; the helper drops the panel-owned ones. - drawerTerminalIds: [ - ...new Set([...terminalUiState.terminalIds, ...activeServerOrderedTerminalIds]), - ], - activeTerminalId: terminalUiState.activeTerminalId, - panelTerminalIds, - runningTerminalIds, - }); - const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; - - setTerminalUiLaunchContext({ - threadId: activeThreadId, - cwd: targetCwd, - worktreePath: targetWorktreePath, - }); - if (!activeThreadRef) { - return; - } - setTerminalFocusRequestId((value) => value + 1); - - const launched = terminalActions.launchTerminal({ - // The drawer opens with the target already placed: opening it empty would claim - // DEFAULT_THREAD_TERMINAL_ID, which a right-panel terminal may already own. - place: (terminalId) => - reusableTerminalId === null - ? storeNewTerminal(activeThreadRef, terminalId) - : storeEnsureTerminal(activeThreadRef, terminalId, { open: true }), - ...(reusableTerminalId === null - ? { size: { cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS } } - : { terminalId: reusableTerminalId }), - location: { cwd: targetCwd, worktreePath: targetWorktreePath }, - ...(options?.env ? { extraEnv: options.env } : {}), - }); - if (!launched) return; - const targetTerminalId = launched.terminalId; - const openResult = await launched.result; - if (openResult._tag === "Failure") { - if (!isAtomCommandInterrupted(openResult)) { - const error = squashAtomCommandFailure(openResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - return; - } - - const writeResult = await terminalActions.writeToTerminal( - targetTerminalId, - options?.shellHistory === "preserve" - ? `${script.command}\r` - : projectScriptTerminalInput(script.command), - ); - if (!writeResult) return; - if (writeResult._tag === "Failure") { - if (!isAtomCommandInterrupted(writeResult)) { - const error = squashAtomCommandFailure(writeResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - return; - } - if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) { - const previewResult = await openUrlInPreview({ - threadRef: activeThreadRef, - url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl), - openPreview, - }); - if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) { - const error = squashAtomCommandFailure(previewResult); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not open preview", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - } - } - }; - const runProjectScriptStable = useStableCallback(runProjectScript); - - const runProjectScriptRef = useRef(runProjectScriptStable); - useLayoutEffect(() => { - runProjectScriptRef.current = runProjectScriptStable; - }, [runProjectScriptStable]); - const runShellCommand = (command: string) => { - void runProjectScriptRef.current( - { - id: "chat-code-block", - name: "Chat code block", - command, - icon: "play", - runOnWorktreeCreate: false, - }, - { rememberAsLastInvoked: false, shellHistory: "preserve" }, - ); - }; - const runShellCommandStable = useStableCallback(runShellCommand); + const { + activatePanelTerminalStable, + addTerminalContextToDraftStable, + addTerminalSurfaceStable, + closePanelTerminalStable, + lastInvokedScriptByProjectId, + runProjectScriptStable, + runShellCommandStable, + splitPanelTerminalStable, + splitPanelTerminalVerticalStable, + toggleTerminalVisibilityStable, + shortcuts: terminalShortcuts, + } = useChatTerminalActions({ + environmentId, + composerRef, + terminalUiState, + activeThread, + activeThreadId, + activeThreadRef, + activeProject, + activeRightPanelSurface, + runningTerminalIds, + activeServerOrderedTerminalIds, + activeTerminalLabelsById, + panelTerminalIds, + canReuseTerminal, + gitCwd, + setTerminalFocusRequestId, + setTerminalUiLaunchContext, + setThreadError, + }); const { saveProjectScript: saveProjectScriptStable, updateProjectScript: updateProjectScriptStable, @@ -3786,80 +1926,10 @@ export default function ChatView(props: ChatViewProps) { scripts: activeProjectScripts, settings, }); - - const handleRuntimeModeChange = (mode: RuntimeMode) => { - if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(composerDraftTarget, mode); - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); - } - scheduleComposerFocus(); - }; - const handleRuntimeModeChangeStable = useStableCallback(handleRuntimeModeChange); - - const handleInteractionModeChange = (mode: ProviderInteractionMode) => { - if (mode === "plan" && !interactionModeEnabled) return; - if (mode === interactionMode) return; - setComposerDraftInteractionMode(composerDraftTarget, mode); - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); - } - scheduleComposerFocus(); - }; - const handleInteractionModeChangeStable = useStableCallback(handleInteractionModeChange); - const toggleInteractionMode = () => { - if (!interactionModeEnabled) return; - handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); - }; - const toggleInteractionModeStable = useStableCallback(toggleInteractionMode); - const openProviderSetup = (instanceId: ProviderInstanceId) => { - void navigate({ - to: "/settings/providers", - search: { environmentId, instanceId }, - }); - }; - const openProviderSetupStable = useStableCallback(openProviderSetup); const canOperatePreview = useEnvironmentScope( activeThreadRef?.environmentId ?? null, AuthPreviewOperateScope, ); - const createBrowserSurface = (profileId?: string) => { - if (!activeThreadRef || !canOperatePreview) return; - void addBrowserSurface({ - threadRef: activeThreadRef, - openPreview, - ...(profileId === undefined ? {} : { profileId }), - }).then((result) => { - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - if (error instanceof BrowserSettingsReadError) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open browser", - description: error.message, - }), - ); - } - }); - }; - const createBrowserSurfaceStable = useStableCallback(createBrowserSurface); - const addDiffSurface = () => { - if (!activeThreadRef || !isServerThread || !isGitRepo) return; - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); - useRightPanelStore.getState().open(activeThreadRef, "diff"); - onDiffPanelOpen?.(); - }; - const addDiffSurfaceStable = useStableCallback(addDiffSurface); - const openChangesFromThreadPanel = () => { - addDiffSurface(); - }; - const openChangesFromThreadPanelStable = useStableCallback(openChangesFromThreadPanel); - const addFilesSurface = () => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().open(activeThreadRef, "files"); - }; - const addFilesSurfaceStable = useStableCallback(addFilesSurface); const supportsThreadPullRequests = serverConfig?.environment.capabilities.threadPullRequests === true; const visiblePullRequests = visibleThreadPullRequests( @@ -3868,23 +1938,9 @@ export default function ChatView(props: ChatViewProps) { const visiblePullRequestCount = visiblePullRequests.length; const pullRequestsSurfaceAvailable = isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0; - const addPullRequestsSurface = () => { - if (!activeThreadRef || !pullRequestsSurfaceAvailable) return; - useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); - }; - const addPullRequestsSurfaceStable = useStableCallback(addPullRequestsSurface); const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); - const addDeviceSurface = () => { - if (!activeThreadRef) return; - if (!deviceState.onboardingCompleted || deviceState.hostStatus === "disabled") { - openDeviceSetupDialog(scopedThreadKey(activeThreadRef)); - return; - } - useRightPanelStore.getState().open(activeThreadRef, "device"); - }; - const addDeviceSurfaceStable = useStableCallback(addDeviceSurface); useAgentOpenedPreviews({ threadRef: activeThreadRef, deviceState, @@ -3895,11 +1951,6 @@ export default function ChatView(props: ChatViewProps) { miniPlayerSource: activePreviewMiniPlayer?.source ?? null, sheetLayout: shouldUsePlanSidebarSheet, }); - const openFileSurface = (relativePath: string) => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); - }; - const openFileSurfaceStable = useStableCallback(openFileSurface); // 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 activeProjectRepository = sourceControlRepositorySelector( @@ -3945,235 +1996,38 @@ export default function ChatView(props: ChatViewProps) { visiblePullRequestCount, onDiffPanelOpen, }); - const closePreviewPanel = () => { - if (activeThreadRef) { - if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId)); - } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); - } - useRightPanelStore.getState().setMaximized(activeThreadRef, false); - useRightPanelStore.getState().close(activeThreadRef); - } - }; - const closePreviewPanelStable = useStableCallback(closePreviewPanel); - const togglePreviewPanel = () => { - if (!activeThreadRef || !browserAvailable) return; - if (previewPanelOpen) { - closePreviewPanel(); - return; + useEffect(() => { + if (!canMaximizeRightPanel) return; + if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { + useRightPanelStore.getState().setMaximized(routeThreadRef, true); } - if (!canOperatePreview) return; - const activeTabId = activePreviewState.activeTabId; - if (activeTabId) { - useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); - } else { - createBrowserSurface(); + }, [canMaximizeRightPanel, routeThreadRef]); + const { + closeSurface: closeRightPanelSurface, + closeOtherSurfaces: closeOtherRightPanelSurfacesStable, + closeSurfacesToRight: closeRightPanelSurfacesToRightStable, + closeAllSurfaces: closeAllRightPanelSurfacesStable, + } = useRightPanelSurfaceClose({ + threadRef: activeThreadRef, + terminalLabelsById: activeTerminalLabelsById, + }); + const closeRightPanelSurfaceStable = closeRightPanelSurface; + const persistThreadSettingsForNextTurn = async (input: { + threadId: ThreadId; + createdAt: string; + branch?: string; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + }): Promise> => { + if (!serverThread) { + return AsyncResult.success(undefined); } - }; - const togglePreviewPanelStable = useStableCallback(togglePreviewPanel); - const addTerminalSurface = () => { - if (!activeThreadRef) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const addTerminalSurfaceStable = useStableCallback(addTerminalSurface); - const splitPanelTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { - if ( - !activeThreadRef || - activeRightPanelSurface?.kind !== "terminal" || - activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP - ) { - return; - } - const surfaceId = activeRightPanelSurface.id; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - useRightPanelStore - .getState() - .splitTerminal(activeThreadRef, surfaceId, terminalId, direction), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const splitPanelTerminalStable = useStableCallback(splitPanelTerminal); - const splitPanelTerminalVertical = () => { - splitPanelTerminal("vertical"); - }; - const splitPanelTerminalVerticalStable = useStableCallback(splitPanelTerminalVertical); - const activatePanelTerminal = (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; - useRightPanelStore - .getState() - .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const activatePanelTerminalStable = useStableCallback(activatePanelTerminal); - const closePanelTerminal = (terminalId: string) => { - if ( - !hasTerminalWriteAccess() || - !activeThreadRef || - activeRightPanelSurface?.kind !== "terminal" - ) - return; - terminalActions.closeTerminalSession(terminalId); - storeCloseTerminal(activeThreadRef, terminalId); - useRightPanelStore - .getState() - .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const closePanelTerminalStable = useStableCallback(closePanelTerminal); - const requestCloseTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); - void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - closeTerminal(terminalId); - } - }); - }; - const requestCloseTerminalStable = useStableCallback(requestCloseTerminal); - const requestClosePanelTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); - void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - closePanelTerminal(terminalId); - } - }); - }; - const requestClosePanelTerminalStable = useStableCallback(requestClosePanelTerminal); - const activateRightPanelSurface = (surface: RightPanelSurface) => { - if (!activeThreadRef) return; - explicitDiffOpenRef.current = resolveExplicitDiffOpenOnActivate({ - surfaceKind: surface.kind, - diffOpen, - activeThreadRef, - }); - useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); - if (surface.kind === "preview" && surface.resourceId) { - setActivePreviewTab(activeThreadRef, surface.resourceId); - } - if (surface.kind === "terminal") { - setTerminalFocusRequestId((value) => value + 1); - } - if (surface.kind === "diff" && !diffOpen) { - onDiffPanelOpen?.(); - } - }; - const activateRightPanelSurfaceStable = useStableCallback(activateRightPanelSurface); - const toggleRightPanel = () => { - if (!activeThreadRef) return; - if (rightPanelOpen) { - closePreviewPanel(); - return; - } - useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }; - const toggleRightPanelStable = useStableCallback(toggleRightPanel); - const toggleThreadPanel = () => { - if (!activeThreadRef) return; - useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); - }; - const toggleThreadPanelStable = useStableCallback(toggleThreadPanel); - useEffect(() => { - if (!canMaximizeRightPanel) return; - if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { - useRightPanelStore.getState().setMaximized(routeThreadRef, true); - } - }, [canMaximizeRightPanel, routeThreadRef]); - const toggleRightPanelMaximized = () => { - if (!canMaximizeRightPanel || !activeThreadRef) return; - // T3-CUSTOM(expbkt3): update the durable width preference. - setRightPanelMaximizedPreference(!rightPanelMaximized); - useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized); - }; - const toggleRightPanelMaximizedStable = useStableCallback(toggleRightPanelMaximized); - const { - closeSurface: closeRightPanelSurface, - closeOtherSurfaces: closeOtherRightPanelSurfacesStable, - closeSurfacesToRight: closeRightPanelSurfacesToRightStable, - closeAllSurfaces: closeAllRightPanelSurfacesStable, - } = useRightPanelSurfaceClose({ - threadRef: activeThreadRef, - terminalLabelsById: activeTerminalLabelsById, - }); - const closeRightPanelSurfaceStable = closeRightPanelSurface; - const moveRightPanelSurface = (surfaceId: string, toIndex: number) => { - if (activeThreadRef) - useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); - }; - const moveRightPanelSurfaceStable = useStableCallback(moveRightPanelSurface); - const copyRightPanelFilePath = (relativePath: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: "Clipboard API unavailable.", - }), - ); - return; - } - - const clipboardPath = formatFilePathForClipboard( - relativePath, - resolvePathLinkTarget(relativePath, gitCwd ?? activeProjectCwd ?? ""), - ); - void navigator.clipboard.writeText(clipboardPath).then( - () => { - toastManager.add({ - type: "success", - title: "Path copied", - description: clipboardPath, - }); - }, - (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }; - const copyRightPanelFilePathStable = useStableCallback(copyRightPanelFilePath); - useEffect( - () => - subscribePreviewAction((action) => { - if (action === "toggle-panel") togglePreviewPanelStable(); - }), - [togglePreviewPanelStable], - ); - const persistThreadSettingsForNextTurn = async (input: { - threadId: ThreadId; - createdAt: string; - branch?: string; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; - }): Promise> => { - if (!serverThread) { - return AsyncResult.success(undefined); - } - - let result: AtomCommandResult = AsyncResult.success(undefined); - const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ - currentModelSelection: serverThread.modelSelection, - currentBranch: serverThread.branch, - ...(input.branch ? { nextBranch: input.branch } : {}), + + let result: AtomCommandResult = AsyncResult.success(undefined); + const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: serverThread.modelSelection, + currentBranch: serverThread.branch, + ...(input.branch ? { nextBranch: input.branch } : {}), }); if (metadataUpdate) { result = mapAtomCommandResult( @@ -4224,168 +2078,38 @@ export default function ChatView(props: ChatViewProps) { return result; }; - // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during - // thread switches. LegendList fires scroll events with isAtEnd=false while - // initialScrollAtEnd is settling; hiding is always immediate. - const showScrollDebouncer = useRef( - new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), - ); - const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null); - const timelineScrollModeRef = useRef("following-end"); - // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd - // re-pins on its own (independent of the refs), so the timeline needs a - // render-visible flag to switch it off once the user scrolls away. - const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); - const pendingTimelineAnchorRef = useRef(null); - const positionedTimelineAnchorRef = useRef(null); - const settledTimelineAnchorRef = useRef(null); - const activeTimelineAnchorIndexRef = useRef(null); - const observedTimelineActivityRef = useRef(null); - const anchorUserScrollGenerationRef = useRef(0); - const cancelPositionRestoreRef = useRef<(() => void) | null>(null); - const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); - const cancelTimelineLiveFollowForUserNavigation = () => { - cancelPositionRestoreRef.current?.(); - anchorUserScrollGenerationRef.current += 1; - const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling"; - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - setTimelineLiveFollowEnabled(false); - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } - if (wasProgrammaticScrollMode) { - // While following or anchoring, our scrollToEnd/scrollToOffset calls can - // sit in LegendList's pending-imperative-scroll queue (it defers them - // while layout settles) and fire seconds later with stale targets, - // yanking the view away after the user scrolled. Starting a new - // imperative scroll cancels everything queued; targeting an item that - // is not in the data makes the new request itself resolve without ever - // scrolling, so this is a pure cancel. - void legendListRef.current?.scrollToItem({ - item: TIMELINE_SCROLL_CANCEL_SENTINEL, - animated: false, - }); - // An already-started animated scroll (behavior: smooth) keeps running in - // the browser regardless of the queue; a same-position instant write is - // the only way to halt it where it is. - const scrollNode = legendListRef.current?.getScrollableNode() as - | { scrollTop?: number } - | null - | undefined; - const currentScrollTop = scrollNode?.scrollTop; - if (scrollNode && typeof currentScrollTop === "number") { - scrollNode.scrollTop = currentScrollTop; - } - } - }; - const cancelTimelineLiveFollowForUserNavigationStable = useStableCallback( - cancelTimelineLiveFollowForUserNavigation, - ); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( + const { + showScrollToBottom, + timelineLiveFollowEnabled, + cancelPositionRestoreRef, + isTimelineAtLogicalEndStable, cancelTimelineLiveFollowForUserNavigationStable, - ); - useEffect(() => { - cancelTimelineLiveFollowForUserNavigationRef.current = - cancelTimelineLiveFollowForUserNavigationStable; - }, [cancelTimelineLiveFollowForUserNavigationStable]); - useEffect(() => { - const previous = observedTimelineActivityRef.current; - const dispatchedUserItem = - previous?.hydrated && - previous.threadKey === activeThreadKey && - activeActivityRun !== null && - previous.runId !== activeActivityRun.runId - ? serverProjection?.visibleTurnItems.find( - (row) => row.item.type === "user_message" && row.item.runId === activeActivityRun.runId, - ) - : undefined; - const { observation, anchorMessageId: messageId } = observeTimelineRun(previous, { - threadKey: activeThreadKey, - hydrated: !isServerThread || (serverProjection !== null && threadStatus === "live"), - runId: activeActivityRun?.runId ?? null, - queued: activeActivityRun?.status === "queued", - messageId: - dispatchedUserItem?.item.type === "user_message" ? dispatchedUserItem.item.messageId : null, - }); - observedTimelineActivityRef.current = observation; - if (messageId === null) return; - if ( - pendingTimelineAnchorRef.current !== null || - timelineScrollModeRef.current === "free-scrolling" - ) { - return; - } - - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = messageId; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ threadKey: activeThreadKey, messageId }); - }, [activeActivityRun, activeThreadKey, isServerThread, serverProjection, threadStatus]); - const timelineRealContentOverflowsViewport = (list?: LegendListRef | null) => - timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), { - composerInset: composerTimelineInset, - anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, - }); - const timelineRealContentOverflowsViewportStable = useStableCallback( - timelineRealContentOverflowsViewport, - ); - const pageScrollControllerRef = useRef | null>( - null, - ); - const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => { - timelineScrollIntentRef.current = key === "PageUp" ? "away-from-end" : "toward-end"; - composerRef.current?.collapseForTimelineScrollKey(key); - if ( - (key === "PageUp" && timelineRealContentOverflowsViewportStable()) || - !isTimelineAtLogicalEndStable() - ) { - cancelTimelineLiveFollowForUserNavigationStable(); - } + onComposerPageScrollKeyDownStable, + onComposerPageScrollKeyUpStable, + onComposerPageScrollReleaseStable, + scrollToEnd, + onTimelineAnchorReadyStable, + onTimelineAnchorSizeChangedStable, + onToolOutputCollapsedAtEndStable, + onIsAtEndChangeStable, + anchorNewTurn, + } = useTimelineFollow({ + legendListRef, + composerRef, + composerOverlayElement, + composerTimelineInset, + routeThreadKey, + activeThreadKey, + activeThreadId: activeThread?.id, + isServerThread, + serverProjection, + threadStatus, + activeActivityRun, + activeRunningTurnId, + timelineEntries, + timelineAnchorMessageId, + setTimelineAnchor, }); - useEffect(() => { - const controller = createPageScrollController({ - getContainer: () => legendListRef.current?.getScrollableNode() ?? null, - getScrollPaddingBottomPx: () => composerOverlayElement?.getBoundingClientRect().height ?? 0, - onScrollStart: handlePageScrollStart, - }); - pageScrollControllerRef.current = controller; - - return () => { - controller.dispose(); - if (pageScrollControllerRef.current === controller) { - pageScrollControllerRef.current = null; - } - }; - }, [composerOverlayElement]); - const onComposerPageScrollKeyDown = (key: PageScrollKey) => { - pageScrollControllerRef.current?.handleKeyDown(key); - }; - const onComposerPageScrollKeyDownStable = useStableCallback(onComposerPageScrollKeyDown); - const onComposerPageScrollKeyUp = (key: string) => { - pageScrollControllerRef.current?.handleKeyUp(key); - }; - const onComposerPageScrollKeyUpStable = useStableCallback(onComposerPageScrollKeyUp); - const onComposerPageScrollRelease = () => { - pageScrollControllerRef.current?.releaseActiveKey(); - }; - const onComposerPageScrollReleaseStable = useStableCallback(onComposerPageScrollRelease); // T3-CUSTOM(expbkt3): a held chat.scrollPage* binding stops on the first keyup // (the bound key or one of its modifiers) or when the window loses focus. const keybindingPageScrollActiveRef = useRef(false); @@ -4393,7 +2117,7 @@ export default function ChatView(props: ChatViewProps) { const release = () => { if (!keybindingPageScrollActiveRef.current) return; keybindingPageScrollActiveRef.current = false; - pageScrollControllerRef.current?.releaseActiveKey(); + onComposerPageScrollReleaseStable(); }; window.addEventListener("keyup", release, true); window.addEventListener("blur", release); @@ -4401,380 +2125,7 @@ export default function ChatView(props: ChatViewProps) { window.removeEventListener("keyup", release, true); window.removeEventListener("blur", release); }; - }, []); - const scrollToEnd = (animated: boolean = false) => { - cancelPositionRestoreRef.current?.(); - isAtEndRef.current = true; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor(releaseChatTimelineAnchor); - // The anchored end space must be gone before the scroll measures, or the - // list lands short of the real end (#6519). - requestAnimationFrame(() => { - void legendListRef.current?.scrollToEnd?.({ - animated: animated && !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - }); - }); - }; - const scrollToEndStable = useStableCallback(scrollToEnd); - useEffect(() => { - const composer = composerRef; - let removeListeners: (() => void) | null = null; - let frame: number | null = null; - const attach = (remainingAttempts: number) => { - frame = requestAnimationFrame(() => { - frame = null; - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - // The list may not have mounted on the first frame after a thread - // switch — without a retry the opt-out listeners never attach and - // live-follow becomes impossible to escape for the whole thread. - if (remainingAttempts > 0) { - attach(remainingAttempts - 1); - } - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - // The gestures below must only break follow when they can actually - // move the viewport away from the live edge (#5566): a spurious break - // while pinned at the end produces no scroll event, never re-arms, - // and streaming silently stops following. Underflowing content can't - // scroll at all, so nothing there should break follow. - const contentScrollsUp = () => timelineRealContentOverflowsViewportStable(); - // The follow re-arm band, not the strict flag: streaming growth makes - // isAtEnd flicker false for a frame before the follow scroll catches - // up, and a gesture landing in that window while still pinned would - // otherwise break follow with no scroll event left to re-arm it. - const viewportIsAwayFromEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; - const handleWheel = (event: WheelEvent) => { - if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) - return; - if (event.deltaY > 0) { - timelineScrollIntentRef.current = "toward-end"; - if (isAtEndRef.current) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - } else if (event.deltaY < 0) { - timelineScrollIntentRef.current = "away-from-end"; - } - if (event.deltaY < 0 && contentScrollsUp()) { - handleManualNavigation(); - } - }; - // Touch direction isn't observable here (touchmove fires on any - // finger motion, scrolling or not), so break only once the drag has - // actually carried the viewport out of the end band — an upward flick - // gets there within its first few events and later touchmoves break. - const handleTouchMove = () => { - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Scrollbar drags produce no wheel/touch events; they are the only - // pointerdowns whose target is the scroll node itself rather than a - // message row. - const handlePointerDown = (event: PointerEvent) => { - if (event.target === scrollNode) { - if (contentScrollsUp()) { - handleManualNavigation(); - } - return; - } - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and - // pointer events entirely; without this the timeline yanks back to - // the end on the next stream chunk. Clicking message text can leave - // DOM focus on body, so these keys must also be heard at document. - const handleKeyDown = (event: KeyboardEvent) => { - if ( - !(event.target instanceof Node) || - (!scrollNode.contains(event.target) && - event.target !== document.body && - event.target !== document.documentElement) || - event.defaultPrevented || - event.isComposing || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || - document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) - ) { - return; - } - if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) - return; - const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; - if ( - scrollNode.contains(event.target) && - !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) - ) - return; - switch (event.key) { - case "PageUp": - case "Home": - case "ArrowUp": - timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp()) { - handleManualNavigation(); - composer.current?.collapseForTimelineScrollKey(event.key); - } - break; - case "PageDown": - case "End": - case "ArrowDown": - timelineScrollIntentRef.current = "toward-end"; - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - composer.current?.collapseForTimelineScrollKey(event.key); - if (isTimelineAtLogicalEndStable()) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - break; - default: - break; - } - }; - // Input inside an embedded frame reaches none of the listeners above, - // but its scroll still chains out to the timeline (#17056). - const scrolledUp = createUpwardScrollDetector({ - top: scrollNode.scrollTop, - height: scrollNode.scrollHeight, - }); - const handleScroll = () => { - if ( - scrolledUp({ - top: scrollNode.scrollTop, - height: scrollNode.scrollHeight, - programmatic: - positionedTimelineAnchorRef.current !== null && - positionedTimelineAnchorRef.current !== settledTimelineAnchorRef.current, - }) && - timelineScrollModeRef.current !== "free-scrolling" && - // LegendList updates its scroll state on the next animation frame. - resolveTimelineIsAtEnd({ - contentLength: scrollNode.scrollHeight, - scroll: scrollNode.scrollTop, - scrollLength: scrollNode.clientHeight, - }) === false - ) { - handleManualNavigation(); - } - }; - scrollNode.addEventListener("scroll", handleScroll, { passive: true }); - scrollNode.addEventListener("wheel", handleWheel, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleTouchMove, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handlePointerDown, { - passive: true, - }); - document.addEventListener("keydown", handleKeyDown); - removeListeners = () => { - scrollNode.removeEventListener("scroll", handleScroll); - scrollNode.removeEventListener("wheel", handleWheel); - scrollNode.removeEventListener("touchmove", handleTouchMove); - scrollNode.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }); - }; - attach(12); - - return () => { - if (frame !== null) { - cancelAnimationFrame(frame); - } - removeListeners?.(); - }; - }, [ - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A thread switch remounts the list's scroll node, so the listeners reattach. - activeThread?.id, - composerRef, - isTimelineAtLogicalEndStable, - timelineRealContentOverflowsViewportStable, - ]); - - const onTimelineAnchorReady = (messageId: MessageId, anchorIndex: number) => { - if (pendingTimelineAnchorRef.current === messageId) { - pendingTimelineAnchorRef.current = null; - } - activeTimelineAnchorIndexRef.current = anchorIndex; - if (positionedTimelineAnchorRef.current === messageId) { - return; - } - positionedTimelineAnchorRef.current = messageId; - settledTimelineAnchorRef.current = null; - const positionAnchor = (remainingAttempts: number) => { - requestAnimationFrame(() => { - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const list = legendListRef.current; - if (!list) { - if (remainingAttempts > 0) { - positionAnchor(remainingAttempts - 1); - } - return; - } - // LegendList waits for queued layout and the animation before resolving. - void list - .scrollToIndex({ - index: anchorIndex, - animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }) - .then(() => { - if ( - legendListRef.current === list && - positionedTimelineAnchorRef.current === messageId - ) { - settledTimelineAnchorRef.current = messageId; - } - }); - }); - }; - requestAnimationFrame(() => positionAnchor(12)); - }; - const onTimelineAnchorReadyStable = useStableCallback(onTimelineAnchorReady); - const onTimelineAnchorSizeChanged = (messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = legendListRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== null) { - return; - } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { - const list = legendListRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }; - const onTimelineAnchorSizeChangedStable = useStableCallback(onTimelineAnchorSizeChanged); - - const onToolOutputCollapsedAtEnd = () => { - composerRef.current?.restoreAfterTimelineReachedEnd(); - }; - const onToolOutputCollapsedAtEndStable = useStableCallback(onToolOutputCollapsedAtEnd); - - const onIsAtEndChange = (isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - return; - } - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - if (timelineScrollIntentRef.current === "toward-end") { - composerRef.current?.restoreAfterTimelineReachedEnd(); - } - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - setTimelineAnchor(releaseChatTimelineAnchor); - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - } else { - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - showScrollDebouncer.current.maybeExecute(); - } - }; - const onIsAtEndChangeStable = useStableCallback(onIsAtEndChange); - - useLayoutEffect(() => { - if (timelineScrollModeRef.current !== "anchoring-new-turn") return; - if ( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId: timelineAnchorMessageId, - liveFollowEnabled: timelineLiveFollowEnabled, - runningTurnId: activeRunningTurnId, - timelineEntries, - }) - ) { - scrollToEndStable(); - } - }, [ - activeRunningTurnId, - scrollToEndStable, - timelineAnchorMessageId, - timelineEntries, - timelineLiveFollowEnabled, - ]); - - // A thread switch restores that thread's remembered follow state; the refs - // the scroll handlers read catch up after commit. - const [timelineThreadReset, setTimelineThreadReset] = useState({ - threadKey: activeThreadKey, - followEnd: true, - }); - if (timelineThreadReset.threadKey !== activeThreadKey) { - const followEnd = readTimelinePosition(routeThreadKey)?.atEnd !== false; - setTimelineThreadReset({ threadKey: activeThreadKey, followEnd }); - setTimelineLiveFollowEnabled(followEnd); - setShowScrollToBottom(!followEnd); - } - useEffect(() => { - const { followEnd } = timelineThreadReset; - isAtEndRef.current = followEnd; - timelineScrollIntentRef.current = null; - timelineScrollModeRef.current = followEnd ? "following-end" : "free-scrolling"; - liveFollowUserScrollGenerationRef.current = followEnd - ? anchorUserScrollGenerationRef.current - : null; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - }, [timelineThreadReset]); + }, [onComposerPageScrollReleaseStable]); useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; @@ -5086,7 +2437,6 @@ export default function ChatView(props: ChatViewProps) { }, ); }; - const copyActiveThreadReferenceStable = useStableCallback(copyActiveThreadReference); const pullRequestPanelTarget = activeThread ? threadPullRequestPanelTarget({ projectId: activeThread.projectId, @@ -5095,512 +2445,105 @@ export default function ChatView(props: ChatViewProps) { branchPullRequest: activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, }) : null; - const addPullRequestSurface = () => { - if (!supportsPullRequests || activeThreadRef === null || pullRequestPanelTarget === null) - return; - useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget); - }; - const addPullRequestSurfaceStable = useStableCallback(addPullRequestSurface); - const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; - const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; - const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; - const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; - const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null; - // The compiler caches render results on their inputs, so a clock read in render - // stays frozen until the shell changes. The clock is state: reset with the shell, - // and moved by the wake timer. - const [snoozeClockState, setSnoozeClockState] = useState(() => ({ - shell: activeThreadShell, - now: new Date().toISOString(), - })); - if (snoozeClockState.shell !== activeThreadShell) { - setSnoozeClockState({ shell: activeThreadShell, now: new Date().toISOString() }); - } - const snoozeClock = snoozeClockState.now; - const activeThreadSnoozed = - activeThreadShell !== null && - supportsSnooze && - effectiveSnoozed(activeThreadShell, { now: snoozeClock }); - useEffect(() => { - if (!activeThreadSnoozed) return; - const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); - if (!Number.isFinite(wakeAtMs)) return; - const id = window.setTimeout( - () => setSnoozeClockState((clock) => ({ ...clock, now: new Date().toISOString() })), - Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647), - ); - return () => window.clearTimeout(id); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A clock that lands before the wake time (timer clamping, clock skew) re-arms the timer. - }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeClock]); - const activeThreadWokeAt = - activeThreadShell !== null && supportsSnooze - ? threadWokeAt(activeThreadShell, { now: snoozeClock }) - : null; - const acknowledgeThreadWoke = useAcknowledgeThreadWoke(); - const acknowledgeActiveThreadWoke = () => { - if (activeThreadRef === null || activeThreadWokeAt === null) return; - acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt); - }; - const acknowledgeActiveThreadWokeStable = useStableCallback(acknowledgeActiveThreadWoke); - const activeThreadLocalVisitedAt = useUiStateStore((store) => - activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], - ); - const activeThreadLastVisitedAt = resolveThreadLastVisitedAt( - activeThreadShell?.lastVisitedAt, - activeThreadLocalVisitedAt, - ); - const activeThreadWokeVisible = isThreadWokeUnseen({ - wokeAt: activeThreadWokeAt, - settled: activeThreadShell?.settledOverride === "settled", - lastVisitedAt: activeThreadLastVisitedAt, - latestRunCompletedAt: activeLatestRun?.completedAt ?? null, - }); - const activeThreadSettled = - supportsSettlement && activeThreadShell?.settledOverride === "settled"; - const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { - reportFailure: false, + const { + activateRightPanelSurfaceStable, + addDeviceSurfaceStable, + addDiffSurfaceStable, + addFilesSurfaceStable, + addPullRequestSurfaceStable, + addPullRequestsSurfaceStable, + closePreviewPanelStable, + copyRightPanelFilePathStable, + createBrowserSurface, + createBrowserSurfaceStable, + moveRightPanelSurfaceStable, + onOpenTurnDiffStable, + openChangesFromThreadPanelStable, + openFileSurfaceStable, + toggleRightPanelMaximizedStable, + toggleRightPanelStable, + toggleThreadPanelStable, + shortcuts: rightPanelShortcuts, + } = useRightPanelActions({ + activeThreadRef, + activeProject, + activeRightPanelSurface, + activePreviewState, + deviceState, + pullRequestPanelTarget, + threadPanelPresentation, + isServerThread, + isGitRepo, + browserAvailable, + canOperatePreview, + diffOpen, + previewPanelOpen, + rightPanelOpen, + canMaximizeRightPanel, + rightPanelMaximized, + supportsPullRequests, + pullRequestsSurfaceAvailable, + gitCwd, + activeProjectCwd, + explicitDiffOpenRef, + setTerminalFocusRequestId, + onDiffPanelOpen, + closeRightPanelSurface, + // T3-CUSTOM(expbkt3): toggling maximize updates the durable width preference. + setRightPanelMaximizedPreference, }); - const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); - const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; - const handleUnsettleActiveThread = async () => { - if ( - !activeThreadRef || - !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) - ) - return; - const threadKey = scopedThreadKey(activeThreadRef); - setUnsettlingThreadKey(threadKey); - // Commands settle into a result, so this always runs. - const result = await unsettleThreadMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, reason: "user" }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); - }; - const handleUnsettleActiveThreadStable = useStableCallback(handleUnsettleActiveThread); - const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { - reportFailure: false, - }); - const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); - const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; - const handleUnsnoozeActiveThread = async () => { - if ( - !activeThreadRef || - !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) - ) - return; - const threadKey = scopedThreadKey(activeThreadRef); - setUnsnoozingThreadKey(threadKey); - // Commands settle into a result, so this always runs. - const recovery = activeThreadShell?.limitRecovery; - const recoveryOwnsSnooze = - recovery?.snooze === true && - recovery.runId === activeThreadShell?.latestRun?.runId && - activeThreadShell?.snoozedUntil != null && - Date.parse(activeThreadShell.snoozedUntil) === Date.parse(recovery.resetAt); - const result = recoveryOwnsSnooze - ? await updateThreadMetadata({ - environmentId: activeThreadRef.environmentId, - input: { - threadId: activeThreadRef.threadId, - limitRecovery: { runId: recovery.runId, resetAt: recovery.resetAt, snooze: false }, - }, - }) - : await unsnoozeThreadMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, reason: "user" }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to wake thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); - }; - const handleUnsnoozeActiveThreadStable = useStableCallback(handleUnsnoozeActiveThread); - const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); - const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState(null); - const [, setBranchMismatchDismissTick] = useState(0); - const composerHasDraftContent = useComposerDraftStore((store) => - composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), - ); + const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; const nowMinute = useNowMinute(); - const activeBranchMismatchKey = branchMismatchKey( - activeThread?.id ?? null, + const { + acknowledgeActiveThreadWoke, + activeBranchMismatchKey, + backgroundWorkBanner, + childInputBanner, + goalBanner, + handleSwitchCheckoutToThread, + handleSwitchCheckoutToThreadStable, + isRestoringThreadBranch, + onOpenRelatedThread, + onOpenRelatedThreadStable, + sendStandaloneCommand, + setBranchMismatchDismissTick, + showBranchMismatchBanner, + threadStatusLine, + shortcuts: threadLifecycleShortcuts, + } = useThreadLifecycle({ + environmentId, + routeThreadKey, + currentRouteThreadKeyRef, + canOperateThread, + clientSettingsHydrated, + activeThread, + activeThreadId, + activeThreadRef, + activeThreadKey, + activeThreadShell, + activeLatestRun, + activeProjectCwd, + serverConfig, + runtimeMode, + isWorking, + pendingBackgroundTasks, + activePendingApproval, + activePendingUserInput, localCheckoutBranchMismatch, - ); - const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ - hasMismatch: localCheckoutBranchMismatch !== null, - isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), - composerHasContent: composerHasDraftContent, - wasShownForCurrentMismatch: - revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, - }); - const nextRevealedBranchMismatchKey = showBranchMismatchBanner - ? activeBranchMismatchKey - : revealedBranchMismatchKey !== activeBranchMismatchKey - ? null - : revealedBranchMismatchKey; - if (nextRevealedBranchMismatchKey !== revealedBranchMismatchKey) { - setRevealedBranchMismatchKey(nextRevealedBranchMismatchKey); - } - const handleSwitchCheckoutToThread = async () => { - if (!canWriteSourceControl) return; - if ( - !activeProjectCwd || - !activeThread || - !localCheckoutBranchMismatch || - isRestoringThreadBranch - ) { - return; - } - setIsRestoringThreadBranch(true); - const checkoutResult = await switchGitRef({ - environmentId, - input: { - cwd: activeProjectCwd, - refName: localCheckoutBranchMismatch.threadBranch, - }, - }); - if (checkoutResult._tag === "Failure") { - setIsRestoringThreadBranch(false); - if (!isAtomCommandInterrupted(checkoutResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to switch checkout", - description: chatActionErrorMessage(squashAtomCommandFailure(checkoutResult)), - }), - ); - } - return; - } - - if (!readEnvironmentScope(environmentId, AuthSourceControlWriteScope)) { - setIsRestoringThreadBranch(false); - return; - } - const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; - if (nextBranch !== activeThread.branch) { - const updateResult = await updateThreadMetadata({ - environmentId, - input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, - }); - if (updateResult._tag === "Failure") { - setIsRestoringThreadBranch(false); - if (!isAtomCommandInterrupted(updateResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Checkout switched, but the thread could not be updated", - description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), - }), - ); - } - gitStatusQuery.refresh(); - return; - } - } - gitStatusQuery.refresh(); - setIsRestoringThreadBranch(false); - scheduleComposerFocus(); - }; - const handleSwitchCheckoutToThreadStable = useStableCallback(() => { - void handleSwitchCheckoutToThread(); + gitStatusQuery, + nowMinute, + composerRef, + composerDraftTarget, + sendInFlightRef, + setOptimisticUserMessages, + setThreadError, + clearUsageLimitsFor, + beginLocalDispatch, + resetLocalDispatch, + scheduleComposerFocus, + scrollToEnd, + persistThreadSettingsForNextTurn, }); - // The interrupt path also accepts a completed run while its provider still - // has background work. - const activeBackgroundTasks = !isWorking && activeThread ? pendingBackgroundTasks : []; - const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState(null); - const isStoppingBackgroundWork = - stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; - const handleStopBackgroundWork = async () => { - if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) - return; - const requestKey = `${environmentId}:${activeThread.id}`; - setStoppingBackgroundWorkKey(requestKey); - const result = await interruptThreadTurn({ - environmentId, - input: { threadId: activeThread.id }, - }); - // Acceptance does not confirm termination. Allow retry while the provider - // finishes stopping the tasks or reports a failure. - setStoppingBackgroundWorkKey((current) => (current === requestKey ? null : current)); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to stop background work.", - ); - } - } - }; - const handleStopBackgroundWorkStable = useStableCallback(handleStopBackgroundWork); - const openRelatedThreadGenerationRef = useRef(0); - const onOpenRelatedThread = async (threadId: ThreadId) => { - const generation = ++openRelatedThreadGenerationRef.current; - const threadRef = scopeThreadRef(environmentId, threadId); - const isEnvironmentLive = () => - appAtomRegistry.get(environmentShell.stateValueAtom(environmentId)).status === "live"; - // A just-launched child can reach this timeline before its shell syncs. - if (isEnvironmentLive() && readThreadShell(threadRef) === null) { - const location = router.latestLocation; - await waitForThreadShell(threadRef, RELATED_THREAD_SHELL_WAIT_MS); - if ( - generation !== openRelatedThreadGenerationRef.current || - currentRouteThreadKeyRef.current !== routeThreadKey || - router.latestLocation !== location - ) { - return; - } - } - const shell = readThreadShell(threadRef); - if (shell === null ? isEnvironmentLive() : shell.deletedAt != null) { - toastManager.add({ type: "info", title: "This thread is no longer available" }); - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }; - const onOpenRelatedThreadStable = useStableCallback(onOpenRelatedThread); - const childThreadInputs = useChildThreadInputs(activeThreadRef); - const childInputBannerItem = (() => { - const first = childThreadInputs[0]; - const parentAwaitingUser = - activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; - if (!first || parentAwaitingUser) return null; - return { - id: `child-input:${first.id}`, - variant: "info", - priority: "activity", - icon: first.hasPendingApprovals ? : , - title: childThreadInputsTitle(childThreadInputs), - description: childThreadInputs.map((child, index) => ( - - {index > 0 ? " " : null} - - onOpenRelatedThreadStable(child.id)}> - {child.title} - - {index < childThreadInputs.length - 1 ? "," : null} - - - )), - actions: ( - - ), - }; - })() as ComposerBannerStackItem | null; - - const sendStandaloneCommand = async (text: string, failureMessage: string) => { - if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return; - const context = composerRef.current?.getSendContext(); - if (!context?.providerAvailable) return; - - const threadId = activeThread.id; - const messageId = newMessageId(); - const createdAt = new Date().toISOString(); - sendInFlightRef.current = true; - beginLocalDispatch(); - setThreadError(threadId, null); - setOptimisticUserMessages((messages) => [ - ...messages, - { - id: messageId, - role: "user", - text, - runId: null, - createdAt, - updatedAt: createdAt, - streaming: false, - }, - ]); - scrollToEnd(); - // Commands settle into a result, so this always runs. - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId, - createdAt, - ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), - runtimeMode, - interactionMode: context.interactionMode, - }); - const result = - settingsResult._tag === "Failure" - ? settingsResult - : await startThreadTurn({ - environmentId, - input: { - threadId, - message: { messageId, role: "user", text, attachments: [] }, - modelSelection: context.selectedModelSelection, - runtimeMode, - interactionMode: context.interactionMode, - createdAt, - }, - }); - if (result._tag === "Failure") { - setOptimisticUserMessages((messages) => - messages.filter((message) => message.id !== messageId), - ); - resetLocalDispatch(); - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError(threadId, error instanceof Error ? error.message : failureMessage); - } - } else { - clearUsageLimitsFor(routeThreadKey); - } - sendInFlightRef.current = false; - }; - const sendStandaloneCommandStable = useStableCallback(sendStandaloneCommand); - const activeGoal = activeThreadShell?.goal ?? null; - const goalBannerItem = (() => { - if (!activeThread || activeGoal === null) return null; - const presentation = presentProviderGoal(activeGoal, isWorking); - return { - id: `goal:${activeThread.id}`, - variant: "default", - priority: "activity", - icon: , - title: - presentation.usage === null - ? presentation.title - : `${presentation.title} · ${presentation.usage}`, - description: presentation.objective, - actions: isWorking ? undefined : ( - <> - {presentation.canResume ? ( - - ) : null} - - - ), - }; - })() as ComposerBannerStackItem | null; - - const backgroundWorkBannerItem = (() => { - const presentation = presentPendingBackgroundWork(activeBackgroundTasks); - if (presentation === null || !activeThread) { - return null; - } - return { - id: `background-work:${activeThread.id}`, - variant: "default", - priority: "activity", - icon: ( -

- -
- ); + const threadPanelHeaderControl = ; const panelLayoutControls = ( -
- {!shouldUsePlanSidebarSheet ? ( - - - - ) : null} -
{panelToggleControls}
-
+ {panelToggleControls} + ); const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ setDragActive: setIsWorkspaceFileDragActive, @@ -8529,46 +3342,30 @@ export default function ChatView(props: ChatViewProps) { onDrop={workspaceFileDropHandlers.onDrop} > - {isWorkspaceFileDragActive ? ( -
-
-
-
- ) : null} -
- setDismissedProviderStatusBannerKey(providerStatusBannerKey)} - onOpenProviderSetup={openProviderSetupStable} - /> - { - setThreadError(activeThread.id, null); - dismissThreadErrorBannerForSession(threadErrorBannerKey); - setThreadErrorBannerDismissTick((tick) => tick + 1); - }} - /> - {/* T3-CUSTOM(expbkt3): durable failure recovery keeps the full payload. */} + {isWorkspaceFileDragActive ? : null} + -
+
{/* T3-CUSTOM(expbkt3): expanded surfaces share the transcript layer. */} @@ -8702,25 +3499,13 @@ export default function ChatView(props: ChatViewProps) { /> {showScrollToBottom && !isThreadFindActive && ( -
- -
+ { + composerRef.current?.restoreAfterTimelineReachedEnd(); + scrollToEnd(true); + }} + /> )}
@@ -9150,37 +3935,39 @@ export default function ChatView(props: ChatViewProps) {
- {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} +
- {rightPanelPresent && !shouldUsePlanSidebarSheet && activeThreadRef ? ( - {panelToggleControls}
+ ) : null + } surfaces={renderedRightPanelSurfaces} environmentId={activeThreadRef.environmentId} activeSurfaceId={renderedRightPanelSurface?.id ?? null} @@ -9217,65 +4004,7 @@ export default function ChatView(props: ChatViewProps) { deviceAvailable={activeThreadRef !== null} > {rightPanelContent} - - ) : null} - {rightPanelPresent && shouldUsePlanSidebarSheet && activeThreadRef ? ( - - {panelToggleControls}
- ) : null - } - surfaces={renderedRightPanelSurfaces} - environmentId={activeThreadRef.environmentId} - activeSurfaceId={renderedRightPanelSurface?.id ?? null} - pendingSurfaceIds={pendingFileSurfaceIds} - previewSessions={activePreviewState.sessions} - desktopByTabId={activePreviewState.desktopByTabId} - previewRuntimeTabId={resolvePreviewRuntimeTabId} - terminalLabelsById={activeTerminalLabelsById} - onActivate={activateRightPanelSurfaceStable} - onCloseSurface={closeRightPanelSurfaceStable} - onRenameDevice={(surfaceId, title) => { - if (activeThreadRef) - useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); - }} - onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable} - onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable} - onCloseAllSurfaces={closeAllRightPanelSurfacesStable} - onMoveSurface={moveRightPanelSurfaceStable} - onCopyFilePath={copyRightPanelFilePathStable} - onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurfaceStable} - onAddTerminal={addTerminalSurfaceStable} - onAddDiff={addDiffSurfaceStable} - onAddFiles={addFilesSurfaceStable} - onAddPullRequest={addPullRequestSurfaceStable} - onAddPullRequests={addPullRequestsSurfaceStable} - onAddDevice={addDeviceSurfaceStable} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} - > - {rightPanelContent} - - + ) : null} @@ -9299,76 +4028,3 @@ function PreviewSessionSync(props: { readonly threadRef: ScopedThreadRef }) { usePreviewSession(props.threadRef); return null; } - -// The visit watermark is the one part of the chat view that follows the -// shell's updatedAt, which ticks while a run streams. Keeping it in a leaf lets -// ChatView read the quiet shell. -function ThreadVisitWatermark(props: { readonly threadRef: ScopedThreadRef }) { - const serverThread = useThreadShell(props.threadRef); - const threadKey = scopedThreadKey(props.threadRef); - const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLocalLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[threadKey], - ); - const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); - const lastDispatchedVisitRef = useRef(null); - const lastVisitDispatchAtRef = useRef(0); - - useEffect(() => { - if (!serverThread?.id) return; - const threadUpdatedAt = Date.parse(serverThread.updatedAt); - if (Number.isNaN(threadUpdatedAt)) return; - const effectiveLastVisitedAt = resolveThreadLastVisitedAt( - serverThread.lastVisitedAt, - activeThreadLocalLastVisitedAt, - ); - const lastVisitedAt = effectiveLastVisitedAt ? Date.parse(effectiveLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; - - if (serverThread.lastVisitedAt !== undefined) { - // Dedupe per watermark — the effect re-runs before the command's echo - // lands. The dedupe also keeps a mark-unread on the open thread sticky: - // the rewind leaves updatedAt untouched, so the already-dispatched key - // skips a fresh visit until new activity lands or the thread is reopened. - const dispatchKey = `${threadKey}:${serverThread.updatedAt}`; - if (lastDispatchedVisitRef.current === dispatchKey) return; - const dispatch = () => { - lastDispatchedVisitRef.current = dispatchKey; - lastVisitDispatchAtRef.current = Date.now(); - void visitThreadMutation({ - environmentId: serverThread.environmentId, - input: { threadId: serverThread.id, visitedAt: serverThread.updatedAt }, - }); - }; - const latestRunCompletedAtMs = serverThread.latestRun?.completedAt - ? Date.parse(serverThread.latestRun.completedAt) - : NaN; - const hasUnseenCompletion = - !Number.isNaN(latestRunCompletedAtMs) && - (Number.isNaN(lastVisitedAt) || latestRunCompletedAtMs > lastVisitedAt); - const elapsed = Date.now() - lastVisitDispatchAtRef.current; - if (hasUnseenCompletion || elapsed >= VISIT_DISPATCH_THROTTLE_MS) { - dispatch(); - return; - } - const timer = setTimeout(dispatch, VISIT_DISPATCH_THROTTLE_MS - elapsed); - return () => clearTimeout(timer); - } - - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - serverThread.updatedAt, - ); - }, [ - activeThreadLocalLastVisitedAt, - markThreadVisited, - threadKey, - serverThread?.environmentId, - serverThread?.id, - serverThread?.lastVisitedAt, - serverThread?.latestRun?.completedAt, - serverThread?.updatedAt, - visitThreadMutation, - ]); - return null; -} diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 3037ef72159df..6fe9b7cfb6939 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,9 +1,19 @@ import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import type { CommandPaletteLinkedThreads } from "../commandPaletteBus"; +import { + addProjectRemoteSourceLabel, + addProjectRemoteSourcePathHint, + buildAddProjectRemoteSourceReadiness, + type AddProjectRemoteSource, + type AddProjectRemoteSourceReadiness, +} from "@t3tools/client-runtime/operations/projects"; import { type EnvironmentId, + type EnvironmentMachineKind, type FilesystemBrowseEntry, type KeybindingCommand, + type SourceControlDiscoveryResult, + type SourceControlRepositoryInfo, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem"; @@ -657,3 +667,87 @@ export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): str return "Enter path (e.g. ~/projects/my-app)"; } } + +export function getEnvironmentBrowsePlatform(os: string | null | undefined): string { + if (os === "windows") { + return "Win32"; + } + if (os === "darwin") { + return "MacIntel"; + } + if (os === "linux") { + return "Linux"; + } + return typeof navigator === "undefined" ? "" : navigator.platform; +} + +export interface AddProjectEnvironmentOption { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly machine: EnvironmentMachineKind; + readonly isPrimary: boolean; + readonly isConnected: boolean; + readonly status: string; +} + +export type AddProjectCloneFlow = + | { + readonly step: "repository"; + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteSource; + } + | { + readonly step: "confirm"; + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteSource; + readonly repositoryInput: string; + readonly repository: SourceControlRepositoryInfo | null; + readonly remoteUrl: string; + }; + +export function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null { + if (!flow) return null; + if (flow.step === "confirm") return null; + if (flow.source === "url") { + return "Enter Git clone URL"; + } + return `Enter ${addProjectRemoteSourceLabel(flow.source)} repository (${addProjectRemoteSourcePathHint(flow.source)})`; +} + +export function buildPaletteRemoteSourceReadiness( + discovery: SourceControlDiscoveryResult | null, +): AddProjectRemoteSourceReadiness { + const readiness = buildAddProjectRemoteSourceReadiness(discovery); + return (source) => { + const entry = readiness(source); + return entry.hint === null + ? entry + : { + ...entry, + hint: entry.hint.replace( + "Open Source Control settings", + "Open Settings -> Source Control", + ), + }; + }; +} + +export function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return "An error occurred."; +} + +const OVERLAY_MODE_BY_COMMAND = { + "commandPalette.toggle": "command", + "filePicker.toggle": "files", + "projectSearch.toggle": "content", +} as const satisfies Partial>; + +export function overlayModeForCommand(command: string | null): SearchOverlayMode | null { + if (command === null) return null; + return command in OVERLAY_MODE_BY_COMMAND + ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] + : null; +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index e0275d5d014e4..4c0bf1e5320d1 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -12,19 +12,16 @@ import { getNewProjectPublishTarget, getNewProjectPublishTargets, getNewProjectRepository, - type NewProjectPublishTarget, normalizePastedCloneUrl, addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, - buildAddProjectRemoteSourceReadiness, sortAddProjectProviderSources, type AddProjectRemoteSource, type AddProjectRemoteSourceReadiness, } from "@t3tools/client-runtime/operations/projects"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { canPreloadBrowsePath, @@ -42,14 +39,9 @@ import { AuthSourceControlWriteScope, AuthFilesystemReadScope, type DesktopWslState, - type ClientSettings, type EnvironmentId, - type EnvironmentMachineKind, type FilesystemBrowseResult, - type ProjectId, - type SourceControlDiscoveryResult, SourceControlProviderKind, - type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; @@ -67,13 +59,10 @@ import { MessageSquareDashedIcon, LinkIcon, MessageSquareIcon, - MonitorIcon, - MoonIcon, PaletteIcon, RotateCcwIcon, SettingsIcon, SquarePenIcon, - SunIcon, TextSearchIcon, } from "lucide-react"; import { requestThreadFindOpen } from "./chat/threadFindActionBus"; @@ -104,15 +93,6 @@ import { import { useClientSettings } from "../hooks/useSettings"; import { useStableCallback } from "../hooks/useStableCallback"; import { useTheme } from "../hooks/useTheme"; -import { useCustomThemes } from "../hooks/useCustomThemes"; -import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme"; -import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; -import { getThemeDefinition } from "../themePalette"; -import { - STANDARD_THEME_CARDS, - getThemeCardDefinition, - ThemePreviewCircle, -} from "./settings/ThemePreviewCircles"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; import { filesystemEnvironment, useFilesystemReadAccess } from "../state/filesystem"; @@ -133,7 +113,6 @@ import { usePrimaryEnvironmentId, } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; -import { useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -175,10 +154,7 @@ import { ADDON_ICON_CLASS, browseInputEndPaddingClass, buildBrowseGroups, - buildCommandPaletteProjectMetadata, - buildProjectActionItems, buildRootGroups, - buildThreadActionItems, buildLinkedThreadActionItems, buildCommandPaletteRows, enumerateCommandPaletteItems, @@ -197,58 +173,62 @@ import { RECENT_THREAD_LIMIT, reduceCommandPaletteUiState, createCommandPaletteSearchState, + type AddProjectCloneFlow, + type AddProjectEnvironmentOption, + buildPaletteRemoteSourceReadiness, + errorMessage, + getEnvironmentBrowsePlatform, + overlayModeForCommand, + remoteProjectInputPlaceholder, reduceCommandPaletteSearchState, type SearchOverlayMode, } from "./CommandPalette.logic"; -import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { - CommandPaletteTitleIcon, + selectCommandPaletteClientSettings, + useEnvironmentLookups, + useProjectLookups, + useSortedProjectGroups, +} from "./CommandPaletteLookups"; +import { + buildNewThreadInProjectItems, + buildProjectSearchItems, +} from "./CommandPaletteProjectItems"; +import { + CommandPaletteSubmitButton, + NewProjectPublishHostPicker, + NewProjectSummary, + RemoteRepositorySummary, + remoteProjectSourceIcon, + sourceControlHostIcon, +} from "./CommandPaletteAddProject"; +import { useCommandPaletteThreadItems } from "./CommandPaletteThreadItems"; +import { + APPEARANCE_OPTIONS, + notifyThemeSaveFailure, + useCommandPaletteThemeItems, +} from "./CommandPaletteThemeItems"; +import { CommandPaletteVirtualizedResults, scrollCommandPaletteRowIntoView, } from "./CommandPaletteResults"; -import { sourceControlIcon } from "~/sourceControlPresentation"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; -import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; -import { - COMMAND_PALETTE_META_ICON_CLASS, - CommandPaletteMetaDot, - ThreadCommandSubtitle, -} from "./ThreadCommandSubtitle"; -import { - ThreadRowLeadingStatus, - ThreadRowTrailingStatus, - ThreadSearchPullRequestNumber, -} from "./ThreadStatusIndicators"; -import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; -import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, - type ProviderInstanceEntry, -} from "../providerInstances"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; -import { Kbd, KbdGroup } from "./ui/kbd"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; -import { getProjectOrderKey } from "../logicalProject"; -import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; -import { - buildSidebarProjectPickerEntries, - buildSidebarProjectSnapshots, -} from "../sidebarProjectGrouping"; -import type { Project } from "../types"; +import { buildSidebarProjectPickerEntries } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; @@ -260,235 +240,9 @@ import { GitHubIcon } from "./Icons"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; -function getEnvironmentBrowsePlatform(os: string | null | undefined): string { - if (os === "windows") { - return "Win32"; - } - if (os === "darwin") { - return "MacIntel"; - } - if (os === "linux") { - return "Linux"; - } - return typeof navigator === "undefined" ? "" : navigator.platform; -} - -interface AddProjectEnvironmentOption { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly machine: EnvironmentMachineKind; - readonly isPrimary: boolean; - readonly isConnected: boolean; - readonly status: string; -} - -type AddProjectCloneFlow = - | { - readonly step: "repository"; - readonly environmentId: EnvironmentId; - readonly source: AddProjectRemoteSource; - } - | { - readonly step: "confirm"; - readonly environmentId: EnvironmentId; - readonly source: AddProjectRemoteSource; - readonly repositoryInput: string; - readonly repository: SourceControlRepositoryInfo | null; - readonly remoteUrl: string; - }; - -function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { - if (source === "url") return ; - const Icon = sourceControlIcon(sourceControlClients.get(source)); - return ; -} - -function sourceControlHostIcon( - definition: NewProjectPublishTarget["definition"], - className: string, -): ReactNode { - const Icon = sourceControlIcon(definition); - return ; -} - -function projectFaviconIcon(project: Project): ReactNode { - return ; -} - -function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null { - if (!flow) return null; - if (flow.step === "confirm") return null; - if (flow.source === "url") { - return "Enter Git clone URL"; - } - return `Enter ${addProjectRemoteSourceLabel(flow.source)} repository (${addProjectRemoteSourcePathHint(flow.source)})`; -} - -function buildPaletteRemoteSourceReadiness( - discovery: SourceControlDiscoveryResult | null, -): AddProjectRemoteSourceReadiness { - const readiness = buildAddProjectRemoteSourceReadiness(discovery); - return (source) => { - const entry = readiness(source); - return entry.hint === null - ? entry - : { - ...entry, - hint: entry.hint.replace( - "Open Source Control settings", - "Open Settings -> Source Control", - ), - }; - }; -} - -function errorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - return "An error occurred."; -} - -const OVERLAY_MODE_BY_COMMAND = { - "commandPalette.toggle": "command", - "filePicker.toggle": "files", - "projectSearch.toggle": "content", -} as const satisfies Partial>; - -function overlayModeForCommand(command: string | null): SearchOverlayMode | null { - if (command === null) return null; - return command in OVERLAY_MODE_BY_COMMAND - ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] - : null; -} - -const APPEARANCE_OPTIONS = [ - { mode: "system", label: "System", icon: MonitorIcon }, - { mode: "light", label: "Light", icon: SunIcon }, - { mode: "dark", label: "Dark", icon: MoonIcon }, -] as const; - -function notifyThemeSaveFailure(): void { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Couldn't save theme selection", - description: "Try again.", - }), - ); -} - -// Each helper reads its own source hook so the compiler caches the derived value on that -// source. Taking the source as an argument instead leaves the helper hook-free, and the -// compiler then treats it as a plain function and caches nothing. -function useThemeCards() { - const customThemes = useCustomThemes(); - const environmentThemes = useEnvironmentThemeDefinitions(); - const seen = new Set(); - return [ - ...STANDARD_THEME_CARDS.map((card) => ({ ...card, id: null })), - ...[...BUILT_IN_THEMES, ...customThemes, ...environmentThemes] - .filter((definition) => { - if (seen.has(definition.id)) return false; - seen.add(definition.id); - return true; - }) - .map(getThemeCardDefinition), - ]; -} - -function useThreadSearchWithContentMatches( - environmentIds: ReadonlyArray, - query: string, -) { - const threadSearch = useThreadSearch(environmentIds, query); - const contentMatchByKey = new Map( - threadSearch.matches.flatMap((match) => - match.source === "user" || match.source === "assistant" - ? [[threadSearchMatchKey(match), match] as const] - : [], - ), - ); - return { threadSearch, contentMatchByKey }; -} - -function useEnvironmentLookups() { - const { environments } = useEnvironments(); - const environmentLabelById = new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), - ); - const projectEnvironmentLocationById = new Map( - environments.map((environment) => { - const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; - const isLocal = isPrimary || isDesktopLocalConnectionTarget(environment.entry.target); - return [ - environment.environmentId, - { - kind: isLocal ? "local" : "remote", - label: isPrimary ? "Local" : isLocal ? `${environment.label} (Local)` : environment.label, - machine: resolveEnvironmentMachineKind(environment.serverConfig), - }, - ] as const; - }), - ); - return { environmentLabelById, projectEnvironmentLocationById }; -} - -function useProjectLookups() { - const projects = useProjects(); - const projectCwdById = new Map( - projects.map((project) => [project.id, project.workspaceRoot]), - ); - const projectByKey = new Map( - projects.map((project) => [`${project.environmentId}:${project.id}`, project]), - ); - const projectTitleById = new Map( - projects.map((project) => [project.id, project.title]), - ); - return { projectCwdById, projectByKey, projectTitleById }; -} - -const selectCommandPaletteClientSettings = (settings: ClientSettings) => ({ - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, - sidebarProjectSortOrder: settings.sidebarProjectSortOrder, - sidebarThreadSortOrder: settings.sidebarThreadSortOrder, -}); - -function useSortedProjectGroups(environmentLabelById: ReadonlyMap) { - const projects = useProjects(); - const threads = useThreadShells(); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const clientSettings = useClientSettings(selectCommandPaletteClientSettings); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const projectGroupingSettings = { - sidebarProjectGroupingMode: clientSettings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: clientSettings.sidebarProjectGroupingOverrides, - }; - const orderedProjects = orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }); - const unsortedProjectGroups = buildSidebarProjectSnapshots({ - projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - }); - return sortLogicalProjectsForSidebar( - unsortedProjectGroups, - threads, - clientSettings.sidebarProjectSortOrder, - ); -} - export function CommandPalette({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { open: false, mode: "command", @@ -539,6 +293,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { previewFocus: isPreviewFocused(), previewOpen, modelPickerOpen: composerHandleRef.current?.isModelPickerOpen() ?? false, + usagePageOpen, }, }); if (command === "appearance.cycle") { @@ -604,6 +359,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { theme, themeHalves, toggleMode, + usagePageOpen, ]); useEffect( @@ -822,40 +578,10 @@ function OpenCommandPaletteDialog(props: { }; const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const { - theme, - themeHalves, - resolvedTheme, - appearanceMode, - setAppearanceMode, - setTheme, - setThemeHalf, - } = useTheme(); - const themeCards = useThemeCards(); - const providers = useAtomValue(primaryServerProvidersAtom); const providersByEnvironment = useEnvironmentProviders(); - const providerEntryByEnvironmentAndInstanceId = new Map( - environments.flatMap((environment) => { - const serverConfig = environment.serverConfig; - const derived = deriveProviderInstanceEntries( - providersByEnvironment.get(environment.environmentId) ?? - (environment.environmentId === primaryEnvironmentId ? providers : []), - ); - const entries = serverConfig - ? applyProviderInstanceSettings(derived, serverConfig.settings, null) - : derived; - return entries.map( - (entry) => [`${environment.environmentId}:${entry.instanceId}`, entry] as const, - ); - }), - ); + const { changeThemeItem, changeAppearanceItem, themeEditorItem } = useCommandPaletteThemeItems(); const currentView = viewStack.at(-1)?.view ?? null; - const environmentIds = environments - .filter((environment) => environment.connection.phase === "connected") - .map((environment) => environment.environmentId); const threadSearchQuery = currentView === null && !isActionsOnly ? deferredQuery : ""; - const { threadSearch, contentMatchByKey: threadContentMatchByKey } = - useThreadSearchWithContentMatches(environmentIds, threadSearchQuery); const [browseGeneration, setBrowseGeneration] = useState(0); const [browseNavigation] = useState(createBrowseNavigationCoordinator); const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( @@ -870,7 +596,6 @@ function OpenCommandPaletteDialog(props: { const [newProjectPublishes, setNewProjectPublishes] = useState(false); const [newProjectPublishKind, setNewProjectPublishKind] = useState(null); - const [newProjectHostPickerOpen, setNewProjectHostPickerOpen] = useState(false); const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); // State lags a render behind, so a repeated Enter could start a second create. const newProjectSubmittingRef = useRef(false); @@ -1125,39 +850,10 @@ function OpenCommandPaletteDialog(props: { await handleNewThread(scopeProjectRef(project.environmentId, project.id)); }; - const projectSearchItems = buildProjectActionItems({ + const projectSearchItems = buildProjectSearchItems({ projects: pickerProjects, - valuePrefix: "project", - searchTerms: (project) => { - const members = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`) - ?.memberProjects ?? [project]; - return buildCommandPaletteProjectMetadata({ - projects: members, - locationByEnvironmentId: projectEnvironmentLocationById, - }).searchTerms; - }, - renderDescription: (project) => { - const members = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`) - ?.memberProjects ?? [project]; - const metadata = buildCommandPaletteProjectMetadata({ - projects: members, - locationByEnvironmentId: projectEnvironmentLocationById, - }); - const location = projectEnvironmentLocationById.get(project.environmentId) ?? { - kind: "remote" as const, - label: "Remote", - machine: "server" as const, - }; - return ( - 1} - location={location} - workspaceRoot={project.workspaceRoot} - /> - ); - }, - icon: projectFaviconIcon, + projectGroupByTargetKey, + locationByEnvironmentId: projectEnvironmentLocationById, runProject: openProjectFromSearch, }); @@ -1165,42 +861,10 @@ function OpenCommandPaletteDialog(props: { const isScratch = (project: CommandPaletteProject) => isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); const projectItems = enumerateCommandPaletteItems( - buildProjectActionItems({ + buildNewThreadInProjectItems({ projects: pickerProjects.filter((project) => !isScratch(project)), - valuePrefix: "new-thread-in", - searchTerms: (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const location = projectEnvironmentLocationById.get(project.environmentId); - return [ - ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? - []), - ...(location ? [location.label] : []), - ]; - }, - renderDescription: (project) => { - const location = projectEnvironmentLocationById.get(project.environmentId) ?? { - kind: "remote", - label: "Remote", - machine: "server" as const, - }; - return ( - - - {location.kind === "remote" ? ( - - ) : null} - {location.label} - - - {project.workspaceRoot} - - ); - }, - icon: projectFaviconIcon, + projectGroupByTargetKey, + locationByEnvironmentId: projectEnvironmentLocationById, runProject: async (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); const contextualRefBelongsToGroup = @@ -1236,70 +900,8 @@ function OpenCommandPaletteDialog(props: { ]; })(); - const allThreadItems = buildThreadActionItems({ - threads, - ...(activeThreadId ? { activeThreadId } : {}), - projectTitleById, - sortOrder: clientSettings.sidebarThreadSortOrder, - icon: , - renderLeadingContent: (thread) => , - renderTrailingContent: (thread) => ( - <> - - - - ), - renderDescription: (thread, { projectTitle }) => { - const modelInstanceId = - thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = - providerEntryByEnvironmentAndInstanceId.get(`${thread.environmentId}:${modelInstanceId}`) ?? - null; - return ( - - ); - }, - getContentMatch: (thread) => { - const match = threadContentMatchByKey.get( - threadSearchMatchKey({ - environmentId: thread.environmentId, - threadId: thread.id, - }), - ); - return match && (match.source === "user" || match.source === "assistant") - ? { - source: match.source, - snippet: match.snippet, - query: threadSearch.query, - } - : undefined; - }, - runThread: async (thread) => { - await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), - }); - }, - }); + const { threadItems: allThreadItems, isSearchPending: isThreadSearchPending } = + useCommandPaletteThreadItems({ activeThreadId, threadSearchQuery }); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); // Stable so the item builders that capture it during render never see the @@ -1868,84 +1470,7 @@ function OpenCommandPaletteDialog(props: { }); } - const changeThemeItem: CommandPaletteSubmenuItem = { - kind: "submenu", - value: "action:change-theme", - searchTerms: ["change theme", "appearance", "colors", "palette"], - title: "Change theme", - icon: , - addonIcon: , - shortcutCommand: "theme.select", - groups: [ - { - value: "themes", - label: "Change theme", - items: themeCards.map(({ id, label, previews }) => ({ - kind: "action", - value: id === null ? "theme:standard" : `theme:palette:${id}`, - title: label, - description: previews.length === 1 ? `For ${previews[0]!.mode} mode` : undefined, - searchTerms: [label, "theme", "appearance"], - icon: , - titleTrailingContent: ( - - {(themeHalves?.[resolvedTheme] ?? getThemeDefinition(theme)?.id ?? null) === id ? ( - Current - ) : null} - - {previews.map((preview) => ( - - ))} - - - ), - run: async () => { - const saved = - previews.length === 1 && id !== null - ? setThemeHalf(previews[0]!.mode, id) - : setTheme(id ?? appearanceMode); - if (!saved) notifyThemeSaveFailure(); - }, - })), - }, - ], - }; actionItems.push(changeThemeItem); - - const changeAppearanceItem: CommandPaletteSubmenuItem = { - kind: "submenu", - value: "action:change-appearance", - searchTerms: ["change appearance", "light", "dark", "system", "mode", "toggle"], - title: "Change appearance", - icon: , - addonIcon: , - shortcutCommand: "appearance.cycle", - groups: [ - { - value: "appearance", - label: "Change appearance", - items: APPEARANCE_OPTIONS.map(({ mode, label, icon: Icon }) => ({ - kind: "action", - value: `appearance:${mode}`, - title: label, - searchTerms: [label, "appearance", "mode"], - icon: , - titleTrailingContent: - appearanceMode === mode ? ( - Current - ) : undefined, - run: async () => { - if (!setAppearanceMode(mode)) notifyThemeSaveFailure(); - }, - })), - }, - ], - }; actionItems.push(changeAppearanceItem); // The parent pushes an intent into this mounted dialog; applying it clears the @@ -2002,21 +1527,7 @@ function OpenCommandPaletteDialog(props: { }); // T3-CUSTOM(expbkt3): END - actionItems.push({ - kind: "action", - value: "action:theme-editor", - searchTerms: ["theme", "appearance", "colors", "palette", "customize"], - title: "Toggle theme editor", - icon: , - shortcutCommand: "themeEditor.toggle", - run: async () => { - toggleThemeEditorForTheme({ - theme, - themeHalves, - initialAppearance: resolvedTheme, - }); - }, - }); + actionItems.push(themeEditorItem); if ( environments.some( @@ -2589,18 +2100,6 @@ function OpenCommandPaletteDialog(props: { group.value === "directories" ? { ...group, label: "Select where to clone" } : group, ); - const remoteProjectContext = (() => { - if (addProjectCloneFlow?.step !== "confirm") { - return null; - } - - return { - title: addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.repositoryInput, - description: addProjectCloneFlow.repository?.url ?? addProjectCloneFlow.remoteUrl, - icon: remoteProjectSourceIcon(addProjectCloneFlow.source, ITEM_ICON_CLASS), - }; - })(); - const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null; const newProjectPathPreview = newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName); @@ -2707,55 +2206,12 @@ function OpenCommandPaletteDialog(props: { titleTrailingContent: ( {newProjectPublishTargets.length > 1 ? ( - // The row toggles on click, so the picker keeps its clicks to itself. The - // row also cancels pointerdown to keep focus in the input, which drops the - // mousedown Select opens on, so the picker opens on click instead. - { - event.stopPropagation(); - // React bubbles the popup's clicks here through its portal; only the - // trigger opens the picker. - if (!event.currentTarget.contains(event.target as Node)) return; - if (!isCreatingNewProject) setNewProjectHostPickerOpen(true); - }} - > - - + ) : null} @@ -3125,106 +2581,56 @@ function OpenCommandPaletteDialog(props: { const inputAccessory = newProjectFlow !== null ? ( - - { - event.preventDefault(); - }} - onClick={() => { - void submitNewProject(); - }} - /> - } - > - {isCreatingNewProject ? "Creating" : "Create"} - - {createShortcutLabel} - - - {`Create (${createShortcutLabel})`} - + { + void submitNewProject(); + }} + /> ) : addProjectCloneFlow?.step === "repository" ? ( - - { - event.preventDefault(); - }} - onClick={() => { - void submitAddProjectCloneFlow(); - }} - /> - } - > - {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} - - Enter - - - {remoteProjectButtonLabel ?? "Continue"} (Enter) - + { + void submitAddProjectCloneFlow(); + }} + /> ) : isBrowsing ? ( - - { - event.preventDefault(); - }} - onClick={() => { - if (relativePathNeedsActiveProject) { - return; - } - if (isCloneDestinationStep) { - void submitAddProjectCloneFlow(resolvedAddProjectPath); - } else { - void handleAddProject(resolvedAddProjectPath); - } - }} - /> - } - > - - {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} - - - {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} - - - - {isCloneDestinationStep && !canCloneProject + - + : "This connection cannot add projects." + } + disabled={ + !canCreateProject || + !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || + relativePathNeedsActiveProject || + (isCloneDestinationStep && (!canCloneProject || isRemoteProjectPending)) + } + onSubmit={() => { + if (relativePathNeedsActiveProject) { + return; + } + if (isCloneDestinationStep) { + void submitAddProjectCloneFlow(resolvedAddProjectPath); + } else { + void handleAddProject(resolvedAddProjectPath); + } + }} + /> ) : null; const footerActionLabel = @@ -3306,38 +2712,21 @@ function OpenCommandPaletteDialog(props: { value={query} > {newProjectPathPreview !== null ? ( -
-
- - - - - - {newProjectName.length > 0 ? newProjectName : "New project"} - - - {newProjectName.length > 0 - ? `Creates ${newProjectPathPreview}` - : `Goes in ${newProjectsRoot}`} - {newProjectEnvironmentLabel === null ? null : ` on ${newProjectEnvironmentLabel}`} - - -
-
+ ) : null} - {remoteProjectContext ? ( -
-
Repository
-
- {remoteProjectContext.icon} - - {remoteProjectContext.title} - - {remoteProjectContext.description} - - -
-
+ {addProjectCloneFlow?.step === "confirm" ? ( + ) : null} {browseAccessError ? (
@@ -3370,42 +2759,10 @@ function OpenCommandPaletteDialog(props: { ? { emptyStateMessage: "Press Enter to create this folder and add it as a project.", } - : threadSearch.isPending + : isThreadSearchPending ? { emptyStateMessage: "Searching thread messages…" } : {})} /> ); } - -function ProjectSearchDescription(props: { - readonly environmentLabels: ReadonlyArray; - readonly grouped: boolean; - readonly location: { - readonly kind: "local" | "remote"; - readonly label: string; - readonly machine: EnvironmentMachineKind; - }; - readonly workspaceRoot: string; -}) { - if (!props.grouped) { - return ( - - - {props.location.kind === "remote" ? ( - - ) : null} - {props.location.label} - - - {props.workspaceRoot} - - ); - } - - return {props.environmentLabels.join(" · ")}; -} diff --git a/apps/web/src/components/CommandPaletteAddProject.tsx b/apps/web/src/components/CommandPaletteAddProject.tsx new file mode 100644 index 0000000000000..808536403a4bb --- /dev/null +++ b/apps/web/src/components/CommandPaletteAddProject.tsx @@ -0,0 +1,171 @@ +import { + type AddProjectRemoteSource, + type NewProjectPublishTarget, +} from "@t3tools/client-runtime/operations/projects"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import type { SourceControlProviderKind } from "@t3tools/contracts"; +import { FolderGit2Icon, LinkIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { sourceControlIcon } from "~/sourceControlPresentation"; +import { ITEM_ICON_CLASS } from "./CommandPalette.logic"; +import { CommandPaletteTitleIcon } from "./CommandPaletteResults"; +import { Button } from "./ui/button"; +import { Kbd, KbdGroup } from "./ui/kbd"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export function remoteProjectSourceIcon( + source: AddProjectRemoteSource, + className: string, +): ReactNode { + if (source === "url") return ; + const Icon = sourceControlIcon(sourceControlClients.get(source)); + return ; +} + +export function sourceControlHostIcon( + definition: NewProjectPublishTarget["definition"], + className: string, +): ReactNode { + const Icon = sourceControlIcon(definition); + return ; +} + +/** The input's trailing submit button for the add-project, clone, and new-project flows. */ +export function CommandPaletteSubmitButton(props: { + readonly label: ReactNode; + readonly shortcutLabel: string; + readonly ariaLabel: string; + readonly tooltip: ReactNode; + readonly disabled: boolean; + readonly onSubmit: () => void; +}) { + return ( + + { + event.preventDefault(); + }} + onClick={props.onSubmit} + /> + } + > + {props.label} + + {props.shortcutLabel} + + + {props.tooltip} + + ); +} + +/** + * Picks which host a new project publishes to. The row it sits in toggles on click, so the + * picker keeps its clicks to itself. The row also cancels pointerdown to keep focus in the + * input, which drops the mousedown Select opens on, so the picker opens on click instead. + */ +export function NewProjectPublishHostPicker(props: { + readonly disabled: boolean; + readonly targets: ReadonlyArray; + readonly value: SourceControlProviderKind; + readonly onValueChange: (kind: SourceControlProviderKind) => void; +}) { + const [open, setOpen] = useState(false); + return ( + { + event.stopPropagation(); + // React bubbles the popup's clicks here through its portal; only the + // trigger opens the picker. + if (!event.currentTarget.contains(event.target as Node)) return; + if (!props.disabled) setOpen(true); + }} + > + + + ); +} + +export function NewProjectSummary(props: { + readonly name: string; + readonly pathPreview: string; + readonly projectsRoot: string | null; + readonly environmentLabel: string | null; +}) { + return ( +
+
+ + + + + + {props.name.length > 0 ? props.name : "New project"} + + + {props.name.length > 0 + ? `Creates ${props.pathPreview}` + : `Goes in ${props.projectsRoot}`} + {props.environmentLabel === null ? null : ` on ${props.environmentLabel}`} + + +
+
+ ); +} + +export function RemoteRepositorySummary(props: { + readonly source: AddProjectRemoteSource; + readonly title: string; + readonly description: string; +}) { + return ( +
+
Repository
+
+ + {remoteProjectSourceIcon(props.source, ITEM_ICON_CLASS)} + + + {props.title} + {props.description} + +
+
+ ); +} diff --git a/apps/web/src/components/CommandPaletteLookups.ts b/apps/web/src/components/CommandPaletteLookups.ts new file mode 100644 index 0000000000000..2b6d6497c0cb4 --- /dev/null +++ b/apps/web/src/components/CommandPaletteLookups.ts @@ -0,0 +1,93 @@ +import { + type ClientSettings, + type EnvironmentId, + type ProjectId, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; + +import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { useClientSettings } from "../hooks/useSettings"; +import { getProjectOrderKey } from "../logicalProject"; +import { useProjects, useThreadShells } from "../state/entities"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; + +// Each helper reads its own source hook so the compiler caches the derived value on that +// source. Taking the source as an argument instead leaves the helper hook-free, and the +// compiler then treats it as a plain function and caches nothing. +export function useEnvironmentLookups() { + const { environments } = useEnvironments(); + const environmentLabelById = new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ); + const projectEnvironmentLocationById = new Map( + environments.map((environment) => { + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + const isLocal = isPrimary || isDesktopLocalConnectionTarget(environment.entry.target); + return [ + environment.environmentId, + { + kind: isLocal ? "local" : "remote", + label: isPrimary ? "Local" : isLocal ? `${environment.label} (Local)` : environment.label, + machine: resolveEnvironmentMachineKind(environment.serverConfig), + }, + ] as const; + }), + ); + return { environmentLabelById, projectEnvironmentLocationById }; +} + +export function useProjectLookups() { + const projects = useProjects(); + const projectCwdById = new Map( + projects.map((project) => [project.id, project.workspaceRoot]), + ); + const projectByKey = new Map( + projects.map((project) => [`${project.environmentId}:${project.id}`, project]), + ); + const projectTitleById = new Map( + projects.map((project) => [project.id, project.title]), + ); + return { projectCwdById, projectByKey, projectTitleById }; +} + +export const selectCommandPaletteClientSettings = (settings: ClientSettings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + sidebarProjectSortOrder: settings.sidebarProjectSortOrder, + sidebarThreadSortOrder: settings.sidebarThreadSortOrder, +}); + +export function useSortedProjectGroups(environmentLabelById: ReadonlyMap) { + const projects = useProjects(); + const threads = useThreadShells(); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const clientSettings = useClientSettings(selectCommandPaletteClientSettings); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = { + sidebarProjectGroupingMode: clientSettings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: clientSettings.sidebarProjectGroupingOverrides, + }; + const orderedProjects = orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }); + const unsortedProjectGroups = buildSidebarProjectSnapshots({ + projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + }); + return sortLogicalProjectsForSidebar( + unsortedProjectGroups, + threads, + clientSettings.sidebarProjectSortOrder, + ); +} diff --git a/apps/web/src/components/CommandPaletteProjectItems.tsx b/apps/web/src/components/CommandPaletteProjectItems.tsx new file mode 100644 index 0000000000000..90373413be4fa --- /dev/null +++ b/apps/web/src/components/CommandPaletteProjectItems.tsx @@ -0,0 +1,117 @@ +import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts"; + +import type { SidebarProjectSnapshot } from "../sidebarProjectGrouping"; +import type { Project } from "../types"; +import { + buildCommandPaletteProjectMetadata, + buildProjectActionItems, + type CommandPaletteProject, + ITEM_ICON_CLASS, +} from "./CommandPalette.logic"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { COMMAND_PALETTE_META_ICON_CLASS, CommandPaletteMetaDot } from "./ThreadCommandSubtitle"; + +export interface CommandPaletteProjectLocation { + readonly kind: "local" | "remote"; + readonly label: string; + readonly machine: EnvironmentMachineKind; +} + +const UNKNOWN_PROJECT_LOCATION: CommandPaletteProjectLocation = { + kind: "remote", + label: "Remote", + machine: "server", +}; + +interface ProjectItemsInput { + readonly projects: ReadonlyArray; + readonly projectGroupByTargetKey: ReadonlyMap; + readonly locationByEnvironmentId: ReadonlyMap; + readonly runProject: (project: CommandPaletteProject) => Promise; +} + +function projectFaviconIcon(project: Project) { + return ; +} + +/** Projects as root search results; a grouped project matches on every member. */ +export function buildProjectSearchItems(input: ProjectItemsInput) { + const membersOf = (project: CommandPaletteProject) => + input.projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`)?.memberProjects ?? [ + project, + ]; + return buildProjectActionItems({ + projects: input.projects, + valuePrefix: "project", + searchTerms: (project) => + buildCommandPaletteProjectMetadata({ + projects: membersOf(project), + locationByEnvironmentId: input.locationByEnvironmentId, + }).searchTerms, + renderDescription: (project) => { + const members = membersOf(project); + const metadata = buildCommandPaletteProjectMetadata({ + projects: members, + locationByEnvironmentId: input.locationByEnvironmentId, + }); + return members.length > 1 ? ( + {metadata.environmentLabels.join(" · ")} + ) : ( + + ); + }, + icon: projectFaviconIcon, + runProject: input.runProject, + }); +} + +/** Projects as targets of the "New thread in..." submenu. */ +export function buildNewThreadInProjectItems(input: ProjectItemsInput) { + return buildProjectActionItems({ + projects: input.projects, + valuePrefix: "new-thread-in", + searchTerms: (project) => { + const group = input.projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const location = input.locationByEnvironmentId.get(project.environmentId); + return [ + ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? []), + ...(location ? [location.label] : []), + ]; + }, + renderDescription: (project) => ( + + ), + icon: projectFaviconIcon, + runProject: input.runProject, + }); +} + +function ProjectLocationDescription(props: { + readonly location: CommandPaletteProjectLocation | undefined; + readonly workspaceRoot: string; +}) { + const location = props.location ?? UNKNOWN_PROJECT_LOCATION; + return ( + + + {location.kind === "remote" ? ( + + ) : null} + {location.label} + + + {props.workspaceRoot} + + ); +} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 9e96b68d6eede..beb8892a4e576 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -3,6 +3,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronRightIcon } from "lucide-react"; import { type ReactNode, type RefObject, useState } from "react"; import { cn } from "~/lib/utils"; +import { textDirection } from "~/textDirection"; import { shortcutLabelForCommand } from "../keybindings"; import { type CommandPaletteActionItem, @@ -154,6 +155,14 @@ export function CommandPaletteTitleIcon(props: { children: ReactNode }) { return {props.children}; } +function CommandPaletteTitle({ title }: { title: ReactNode }) { + return ( + + {title} + + ); +} + function DisabledCommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; }) { @@ -164,7 +173,7 @@ function DisabledCommandPaletteResultRow(props: { {props.item.titleLeadingContent} - {props.item.title} + {props.item.threadContentMatch ? ( @@ -178,7 +187,7 @@ function DisabledCommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - {props.item.title} + )} {props.item.titleTrailingContent} @@ -214,7 +223,7 @@ function CommandPaletteResultRow(props: { {props.item.titleLeadingContent} - {props.item.title} + {props.item.threadContentMatch ? ( @@ -228,7 +237,7 @@ function CommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - {props.item.title} + )} {props.item.titleTrailingContent} diff --git a/apps/web/src/components/CommandPaletteThemeItems.tsx b/apps/web/src/components/CommandPaletteThemeItems.tsx new file mode 100644 index 0000000000000..818229d950668 --- /dev/null +++ b/apps/web/src/components/CommandPaletteThemeItems.tsx @@ -0,0 +1,163 @@ +import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; +import { MonitorIcon, MoonIcon, PaletteIcon, SunIcon } from "lucide-react"; + +import { useCustomThemes } from "../hooks/useCustomThemes"; +import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme"; +import { useTheme } from "../hooks/useTheme"; +import { getThemeDefinition } from "../themePalette"; +import { + ADDON_ICON_CLASS, + ITEM_ICON_CLASS, + type CommandPaletteActionItem, + type CommandPaletteSubmenuItem, +} from "./CommandPalette.logic"; +import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; +import { + STANDARD_THEME_CARDS, + getThemeCardDefinition, + ThemePreviewCircle, +} from "./settings/ThemePreviewCircles"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +export const APPEARANCE_OPTIONS = [ + { mode: "system", label: "System", icon: MonitorIcon }, + { mode: "light", label: "Light", icon: SunIcon }, + { mode: "dark", label: "Dark", icon: MoonIcon }, +] as const; + +export function notifyThemeSaveFailure(): void { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't save theme selection", + description: "Try again.", + }), + ); +} + +// Reads its own source hooks so the compiler caches the cards on them. Taking the +// sources as arguments would leave it hook-free, and the compiler would cache nothing. +function useThemeCards() { + const customThemes = useCustomThemes(); + const environmentThemes = useEnvironmentThemeDefinitions(); + const seen = new Set(); + return [ + ...STANDARD_THEME_CARDS.map((card) => ({ ...card, id: null })), + ...[...BUILT_IN_THEMES, ...customThemes, ...environmentThemes] + .filter((definition) => { + if (seen.has(definition.id)) return false; + seen.add(definition.id); + return true; + }) + .map(getThemeCardDefinition), + ]; +} + +export function useCommandPaletteThemeItems() { + const { + theme, + themeHalves, + resolvedTheme, + appearanceMode, + setAppearanceMode, + setTheme, + setThemeHalf, + } = useTheme(); + const themeCards = useThemeCards(); + + const changeThemeItem: CommandPaletteSubmenuItem = { + kind: "submenu", + value: "action:change-theme", + searchTerms: ["change theme", "appearance", "colors", "palette"], + title: "Change theme", + icon: , + addonIcon: , + shortcutCommand: "theme.select", + groups: [ + { + value: "themes", + label: "Change theme", + items: themeCards.map(({ id, label, previews }) => ({ + kind: "action", + value: id === null ? "theme:standard" : `theme:palette:${id}`, + title: label, + description: previews.length === 1 ? `For ${previews[0]!.mode} mode` : undefined, + searchTerms: [label, "theme", "appearance"], + icon: , + titleTrailingContent: ( + + {(themeHalves?.[resolvedTheme] ?? getThemeDefinition(theme)?.id ?? null) === id ? ( + Current + ) : null} + + {previews.map((preview) => ( + + ))} + + + ), + run: async () => { + const saved = + previews.length === 1 && id !== null + ? setThemeHalf(previews[0]!.mode, id) + : setTheme(id ?? appearanceMode); + if (!saved) notifyThemeSaveFailure(); + }, + })), + }, + ], + }; + + const changeAppearanceItem: CommandPaletteSubmenuItem = { + kind: "submenu", + value: "action:change-appearance", + searchTerms: ["change appearance", "light", "dark", "system", "mode", "toggle"], + title: "Change appearance", + icon: , + addonIcon: , + shortcutCommand: "appearance.cycle", + groups: [ + { + value: "appearance", + label: "Change appearance", + items: APPEARANCE_OPTIONS.map(({ mode, label, icon: Icon }) => ({ + kind: "action", + value: `appearance:${mode}`, + title: label, + searchTerms: [label, "appearance", "mode"], + icon: , + titleTrailingContent: + appearanceMode === mode ? ( + Current + ) : undefined, + run: async () => { + if (!setAppearanceMode(mode)) notifyThemeSaveFailure(); + }, + })), + }, + ], + }; + + const themeEditorItem: CommandPaletteActionItem = { + kind: "action", + value: "action:theme-editor", + searchTerms: ["theme", "appearance", "colors", "palette", "customize"], + title: "Toggle theme editor", + icon: , + shortcutCommand: "themeEditor.toggle", + run: async () => { + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + }, + }; + + return { changeThemeItem, changeAppearanceItem, themeEditorItem }; +} diff --git a/apps/web/src/components/CommandPaletteThreadItems.tsx b/apps/web/src/components/CommandPaletteThreadItems.tsx new file mode 100644 index 0000000000000..e2732f5419082 --- /dev/null +++ b/apps/web/src/components/CommandPaletteThreadItems.tsx @@ -0,0 +1,150 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; +import { MessageSquareIcon } from "lucide-react"; + +import { useClientSettings } from "../hooks/useSettings"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + type ProviderInstanceEntry, +} from "../providerInstances"; +import { useThreadShells } from "../state/entities"; +import { + useEnvironmentProviders, + useEnvironments, + usePrimaryEnvironmentId, +} from "../state/environments"; +import { useThreadSearch } from "../state/queries"; +import { primaryServerProvidersAtom } from "../state/server"; +import { buildThreadRouteParams } from "../threadRoutes"; +import { buildThreadActionItems, ITEM_ICON_CLASS } from "./CommandPalette.logic"; +import { useEnvironmentLookups, useProjectLookups } from "./CommandPaletteLookups"; +import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle"; +import { + ThreadRowLeadingStatus, + ThreadRowTrailingStatus, + ThreadSearchPullRequestNumber, +} from "./ThreadStatusIndicators"; + +function useProviderEntryByEnvironmentAndInstanceId() { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const providers = useAtomValue(primaryServerProvidersAtom); + const providersByEnvironment = useEnvironmentProviders(); + return new Map( + environments.flatMap((environment) => { + const serverConfig = environment.serverConfig; + const derived = deriveProviderInstanceEntries( + providersByEnvironment.get(environment.environmentId) ?? + (environment.environmentId === primaryEnvironmentId ? providers : []), + ); + const entries = serverConfig + ? applyProviderInstanceSettings(derived, serverConfig.settings, null) + : derived; + return entries.map( + (entry) => [`${environment.environmentId}:${entry.instanceId}`, entry] as const, + ); + }), + ); +} + +function useThreadSearchWithContentMatches(query: string) { + const { environments } = useEnvironments(); + const environmentIds: ReadonlyArray = environments + .filter((environment) => environment.connection.phase === "connected") + .map((environment) => environment.environmentId); + const threadSearch = useThreadSearch(environmentIds, query); + const contentMatchByKey = new Map( + threadSearch.matches.flatMap((match) => + match.source === "user" || match.source === "assistant" + ? [[threadSearchMatchKey(match), match] as const] + : [], + ), + ); + return { threadSearch, contentMatchByKey }; +} + +/** Every thread as a palette item, with message matches for a non-empty `threadSearchQuery`. */ +export function useCommandPaletteThreadItems(input: { + readonly activeThreadId: ThreadId | undefined; + readonly threadSearchQuery: string; +}) { + const { activeThreadId, threadSearchQuery } = input; + const navigate = useNavigate(); + const threads = useThreadShells(); + const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); + const { projectEnvironmentLocationById } = useEnvironmentLookups(); + const providerEntryByEnvironmentAndInstanceId = useProviderEntryByEnvironmentAndInstanceId(); + const { threadSearch, contentMatchByKey } = useThreadSearchWithContentMatches(threadSearchQuery); + const { projectByKey, projectTitleById } = useProjectLookups(); + + const threadItems = buildThreadActionItems({ + threads, + ...(activeThreadId ? { activeThreadId } : {}), + projectTitleById, + sortOrder: sidebarThreadSortOrder, + icon: , + renderLeadingContent: (thread) => , + renderTrailingContent: (thread) => ( + <> + + + + ), + renderDescription: (thread, { projectTitle }) => { + const modelInstanceId = + thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = + providerEntryByEnvironmentAndInstanceId.get(`${thread.environmentId}:${modelInstanceId}`) ?? + null; + return ( + + ); + }, + getContentMatch: (thread) => { + const match = contentMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ); + return match && (match.source === "user" || match.source === "assistant") + ? { + source: match.source, + snippet: match.snippet, + query: threadSearch.query, + } + : undefined; + }, + runThread: async (thread) => { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, + }); + + return { threadItems, isSearchPending: threadSearch.isPending }; +} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 389c97394f374..751e7e36b8f0b 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -10,7 +10,8 @@ import { import type { ServerProviderSkill } from "@t3tools/contracts"; import type { Editor } from "@tiptap/core"; import { createElement, createRef, useEffect, useState } from "react"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; import { act, render } from "../../test/render"; @@ -352,3 +353,63 @@ describe("composer context undo lifetime", () => { }, ); }); + +describe("an Arabic line", () => { + async function focusEnd(editor: Editor) { + await act(async () => { + editor.commands.focus("end"); + }); + await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom)); + } + + it("leaves inline code through its start with ArrowRight, which points back", async () => { + // Positions: اكتب at 1-5, sumBy at 6-11, الان at 12-16. + await using screen = await mountEditor("اكتب `sumBy` الان"); + const { editor } = screen; + await focusEnd(editor); + const caret = () => ({ + head: editor.state.selection.head, + inCode: (editor.state.storedMarks ?? editor.state.selection.$head.marks()).length > 0, + }); + const visited = [caret()]; + while (visited.at(-1)!.head > 5) { + const before = visited.at(-1)!; + await userEvent.keyboard("{ArrowRight}"); + await vi.waitFor(() => { + const after = caret(); + expect(after.head < before.head || after.inCode !== before.inCode).toBe(true); + }); + visited.push(caret()); + } + expect(visited.some((stop) => stop.inCode)).toBe(true); + }); + + it("shows the backticks on either side of inline code it is editing", async () => { + await using screen = await mountEditor("اكتب `sumBy` الان"); + const { editor } = screen; + await act(async () => { + editor.commands.setTextSelection("اكتب su".length + 1); + }); + const code = editor.view.dom.querySelector("code")!; + const word = document.createRange(); + word.selectNodeContents([...code.childNodes].find((node) => node instanceof Text)!); + const text = word.getBoundingClientRect(); + const [open, close] = [...editor.view.dom.querySelectorAll(".composer-rich-marker")].map( + (marker) => marker.getBoundingClientRect(), + ); + expect(open!.right).toBeLessThanOrEqual(text.left + 1); + expect(close!.left).toBeGreaterThanOrEqual(text.right - 1); + }); + + it("starts the next line on the right", async () => { + await using screen = await mountEditor("اكتب الدالة"); + const { editor } = screen; + await focusEnd(editor); + await userEvent.keyboard("{Shift>}{Enter}{/Shift}"); + await vi.waitFor(() => expect(editor.state.doc.childCount).toBe(2)); + const box = editor.view.dom.getBoundingClientRect(); + expect(editor.view.coordsAtPos(editor.state.selection.head).left).toBeGreaterThan( + box.left + box.width / 2, + ); + }); +}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index f03335afb9aa1..824d061496b91 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -56,6 +56,7 @@ import { pastedPathQueryLength, } from "~/composer-logic"; import { useStableCallback } from "~/hooks/useStableCallback"; +import { caretLineDirection, ComposerTextDirectionExtension } from "~/composer-text-direction"; import { collectComposerPromptInlineTokens, selectionTouchesMentionBoundary, @@ -264,6 +265,7 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { const chip = ( } onClick={() => actions.openMention(path)} aria-label={`Preview ${path}`} @@ -727,6 +729,7 @@ const ComposerMarkerPlugin = new Plugin({ function decorationsForSelection(state: EditorState): DecorationSet { const { doc, selection } = state; const caretSide = caretTakesMarksBefore(state) ? 3 : 0; + const rtl = caretLineDirection(state) === "rtl"; const decorations: Decoration[] = []; if (!selection.empty) { doc.nodesBetween(selection.from, selection.to, (node, pos) => { @@ -750,8 +753,19 @@ function decorationsForSelection(state: EditorState): DecorationSet { marker.className = "composer-rich-marker"; marker.textContent = text; marker.setAttribute("aria-hidden", "true"); + // Inline code is isolated left to right. In a right-to-left line its + // opening backtick joins the closing one inside the isolate, or the line + // would put both on the same side of the code. + const codeMarks = + rtl && baseSide === -1 && /^`+$/.test(text) + ? doc.resolve(at).nodeAfter?.marks.filter((mark) => mark.type.spec.code) + : undefined; decorations.push( - Decoration.widget(at, marker, { side, key: `marker-${at}-${side}-${marker.textContent}` }), + Decoration.widget(at, marker, { + side, + key: `marker-${at}-${side}-${marker.textContent}`, + ...(codeMarks?.length ? { marks: codeMarks } : {}), + }), ); } } @@ -852,6 +866,7 @@ function createComposerExtensions(richText: boolean) { ...(richText ? {} : { bold: false, strike: false }), }), ComposerUndoGroupingExtension, + ComposerTextDirectionExtension, ComposerMentionExtension, ComposerSkillExtension, ComposerCitationExtension, @@ -1146,6 +1161,8 @@ function ComposerPromptEditorTiptapInner( editorProps: { attributes: editorAttributes, handleKeyDown: (view, event) => { + // In a right-to-left line the start is on the right. + const backArrow = caretLineDirection(view.state) === "rtl" ? "ArrowRight" : "ArrowLeft"; const boundary = event.metaKey ? event.key === "ArrowLeft" || event.key === "ArrowRight" ? "lineboundary" @@ -1171,7 +1188,7 @@ function ComposerPromptEditorTiptapInner( event.preventDefault(); event.stopPropagation(); const backward = - event.key === "Home" || event.key === "ArrowLeft" || event.key === "ArrowUp"; + event.key === "Home" || event.key === "ArrowUp" || event.key === backArrow; selection.modify( event.shiftKey ? "extend" : "move", backward ? "backward" : "forward", @@ -1206,7 +1223,7 @@ function ComposerPromptEditorTiptapInner( view.state.selection.empty ) { const { $from } = view.state.selection; - const direction = event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1; + const direction = event.key === "ArrowUp" || event.key === backArrow ? -1 : 1; const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight"; // At the document's ends, native Up/Down can cross a marker without // changing the document position. Record the stop so DOM selection diff --git a/apps/web/src/components/ProjectCloneToastCoordinator.tsx b/apps/web/src/components/ProjectCloneToastCoordinator.tsx index 974138bf2f630..79c927d6e2d02 100644 --- a/apps/web/src/components/ProjectCloneToastCoordinator.tsx +++ b/apps/web/src/components/ProjectCloneToastCoordinator.tsx @@ -45,7 +45,7 @@ interface TrackedToast { } function renderKey(clone: ProjectCloneSnapshot): string { - return `${clone.phase}:${clone.stage}:${clone.percent ?? ""}:${clone.detail ?? ""}:${clone.error ?? ""}`; + return `${clone.phase}:${clone.stage}:${clone.percent ?? ""}:${clone.detail ?? ""}:${clone.stalled === true}:${clone.error ?? ""}`; } async function runCloneAction( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f3d51fe2031a4..97e73dbfd5d21 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -101,7 +101,7 @@ import { } from "~/fork/threadComments/threadCommentsSurface"; import { observeResize } from "~/lib/observeResize"; -interface RightPanelTabsProps { +export interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; open?: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b8fe9450b5889..9447ce0ba6611 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,8 +1,5 @@ import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; -import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard"; -import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; -import { ThreadTerminalStatusButton } from "./ThreadStatusIndicators"; import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; import { dropThreadContext, @@ -11,12 +8,9 @@ import { } from "./chat/threadContextDrag"; import { discardComposerDraft } from "../lib/discardComposerDraft"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; -import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import * as Schema from "effect/Schema"; import { DndContext, @@ -27,29 +21,17 @@ import { type DragStartEvent, type Modifier, } from "@dnd-kit/core"; -import { SortableContext, useSortable } from "@dnd-kit/sortable"; +import { SortableContext } from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { CSS } from "@dnd-kit/utilities"; import { canSnooze, effectiveSnoozed, - hasQueuedTurnStart, - QUEUED_TURN_START_GRACE_MS, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox"; -import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; +import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { - resolveSettledThreadTimestamp, - sortSettledThreads, -} from "@t3tools/client-runtime/state/thread-sort"; -import { - threadSearchMatchKey, - type EnvironmentThreadSearchMatch, -} from "@t3tools/client-runtime/state/thread-search"; -import { - formatWorkingDurationLabel, - resolveThreadProviderStack, threadRuntimeCanArchive, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/models"; @@ -61,38 +43,12 @@ import { } from "@t3tools/client-runtime/environment"; import { AuthOrchestrationOperateScope, - type EnvironmentMachineKind, type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; -import type { TimestampFormat } from "@t3tools/contracts/settings"; -import { - AlarmClockIcon, - AlarmClockOffIcon, - ArrowRightLeftIcon, - CheckIcon, - CircleAlertIcon, - CircleCheckIcon, - CircleDashedIcon, - CircleDotDashedIcon, - ClockIcon, - FolderIcon, - GitBranchIcon, - ListFilterIcon, - MessageCircleQuestionIcon, - PinIcon, - PinOffIcon, - PlusIcon, - SettingsIcon, - ShieldQuestionIcon, - SquarePenIcon, - TerminalIcon, - Undo2Icon, - XIcon, -} from "lucide-react"; +import { FolderIcon, ListFilterIcon, PlusIcon, SettingsIcon } from "lucide-react"; import { - memo, useEffect, useLayoutEffect, useReducer, @@ -100,12 +56,10 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, - type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import { useRouter } from "@tanstack/react-router"; -import { useRightPanelStore } from "../rightPanelStore"; import { isAtomCommandInterrupted, settlePromise, @@ -126,7 +80,6 @@ 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 { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { selectProjectGroupingSettings } from "../logicalProject"; @@ -152,7 +105,6 @@ import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; -import { useNowSecond } from "../hooks/useNowSecond"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, @@ -164,26 +116,19 @@ import { useAllEnvironmentProjectSnapshotsReady, useProjects, useServerConfigs, - useThreadShells, + useQuietThreadShells, } from "../state/entities"; import { environmentPresentations } from "../state/presentation"; import { primaryServerKeybindingsAtom } from "../state/server"; import { environmentProjects } from "../state/projects"; -import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; -import { useEnvironmentQuery } from "../state/query"; import { useThreadSearch } from "../state/queries"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; -import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; +import { readEnvironmentScope } from "../state/session"; import { buildThreadRouteParams, resolveActiveThreadRouteRef } from "../threadRoutes"; -import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; -// T3-CUSTOM(expbkt3): a phone has no right-click, so a held press stands in for it. -import { useLongPressContextMenu } from "../mobile/useLongPressContextMenu"; -import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; -import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildDraftActionMenuItems, @@ -191,7 +136,6 @@ import { threadActionRequiresOperate, } from "./threadActionMenu.logic"; import { - animateSidebarLayoutChanges, applySidebarThreadDrop, filterSidebarV2VisibleThreads, buildBulkTitleRegenerationContextMenuItem, @@ -199,7 +143,6 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, firstValidTimestampMs, - hasUnseenCompletion, isSidebarNestedLinkClick, isSidebarThreadWorking, isTrailingDoubleClick, @@ -211,31 +154,20 @@ import { resolveSidebarDropTarget, resolveSidebarDropVerb, resolveSidebarThreadSection, - resolveSidebarRowAccessibility, - type SidebarDropVerb, - resolveSidebarThreadStatus, - resolveThreadLastVisitedAt, searchSidebarThreads, shouldCreateNewThreadInCurrentProject, shouldNavigateAfterThreadPark, - shouldRecedeSidebarThread, - resolveWorkingStartedAt, sidebarListItemId, - sidebarMarkerId, sidebarThreadKeyAtY, sortInboxThreadsByReturn, sortPinnedThreadsForSidebar, sortThreadsForSidebar, sortWorkingThreadsBySend, useThreadJumpHintVisibility, - useRetainedValue, - useSidebarRowSubscriptionLease, type SidebarListItem, - type SidebarListMarker, type SidebarSection, resolveSidebarRowThreadId, } from "./Sidebar.logic"; -import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { createSidebarCollisionDetection, createSidebarSortingStrategy, @@ -243,32 +175,12 @@ import { } from "./Sidebar.drag"; import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; -import { - ThreadPullRequestBadgeControl, - ThreadPullRequestsMiniList, - ThreadWorktreeIndicator, - prStatusIndicator, - resolveThreadPullRequestBadge, - terminalStatusFromRunningIds, - type TerminalStatusIndicator, - useLinkedThreadPullRequest, - ThreadSearchPullRequestNumber, -} from "./ThreadStatusIndicators"; import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; -import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; -import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; -import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; -import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; -import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; -import { - deriveProviderEntriesByEnvironment, - shouldShowInstanceBadge, - type ProviderInstanceEntry, -} from "../providerInstances"; -import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { deriveProviderEntriesByEnvironment } from "../providerInstances"; import { useStableCallback } from "../hooks/useStableCallback"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { Button, InlineButton } from "./ui/button"; +import { Button } from "./ui/button"; import { Combobox, ComboboxEmpty, @@ -282,33 +194,21 @@ import { import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; -import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "./ui/menu"; -import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; -import { MiddleTruncate } from "./ui/middle-truncate"; -import { - composerDraftHasUserContent, - DraftId, - useComposerDraftStore, - useThreadHasUnsentDraft, - type ComposerThreadDraftState, - type DraftSessionState, -} from "../composerDraftStore"; +import { TooltipProvider } from "./ui/tooltip"; +import { composerDraftHasUserContent, DraftId, useComposerDraftStore } from "../composerDraftStore"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; - -// The fields thread rows show. Provider status pushes refresh `checkedAt`, -// status and auth on every provider; leaving those out keeps each -// environment's map, and every memoized row, stable across them. -type SidebarProviderEntry = Pick< - ProviderInstanceEntry, - | "instanceId" - | "driverKind" - | "enabled" - | "displayName" - | "accentColor" - | "acpRegistryAgentId" - | "acpRegistryIconUrl" - | "models" ->; +import { + SidebarSearchResultRow, + SortableThreadRow, + type SidebarProviderEntry, + type SidebarSweepAction, +} from "./sidebar/SidebarThreadRow"; +import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock"; +import { + SidebarDragBoundary, + SidebarSectionHeader, + SidebarSectionPlaceholder, +} from "./sidebar/SidebarListMarkers"; function sameSidebarProviderEntries( left: ReadonlyMap, @@ -456,7 +356,7 @@ function useSidebarThreads(input: { }) { const { projectScopeKey, scopedProjectKeys, serverConfigs, optimisticDrop, routeThreadRef } = input; - const threads = useThreadShells(); + const threads = useQuietThreadShells(); const routeRowThreadKey = routeThreadRef === null ? null @@ -837,1890 +737,10 @@ function checkThreadOperations( return false; } -function compactSidebarTimeLabel(label: string): string { - if (label === "just now") return "now"; - return label.endsWith(" ago") ? label.slice(0, -4) : label; -} - -// Subscribes to the minute tick itself, so only the label re-renders each -// minute. The minute is passed as the clock so it is the formatter's input. -function RelativeTimeLabel(props: { timestamp: string | null }) { - const nowMinute = useNowMinute(); - return props.timestamp === null - ? null - : compactSidebarTimeLabel( - formatRelativeTimeLabel(props.timestamp, Date.parse(`${nowMinute}:00.000Z`)), - ); -} - -function threadTimeTimestamp(thread: SidebarThreadSummary): string { - return thread.latestUserMessageAt ?? thread.updatedAt; -} - -function JumpHintBadge(props: { label: string }) { - return ( - - {props.label} - - ); -} - -function WorkingDuration(props: { startedAt: string | null }) { - const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; - if (Number.isNaN(startedMs)) return null; - return ; -} - -// Self-ticking so only this span re-renders each second, not the whole row; -// the shared tick lands every working row's update in the same frame. -function WorkingDurationLabel(props: { startedMs: number }) { - const nowMs = useNowSecond(); - return ( - {formatWorkingDurationLabel(nowMs - props.startedMs)} - ); -} - -function terminalProcessLabel(count: number): string { - return `${count} terminal ${count === 1 ? "process" : "processes"} running`; -} - -function SidebarProviderStack(props: { - thread: SidebarThreadSummary; - providerEntryByInstanceId: ReadonlyMap; -}) { - const stack = resolveThreadProviderStack(props.thread); - const currentInstanceId = stack[stack.length - 1]!; - const currentEntry = props.providerEntryByInstanceId.get(currentInstanceId) ?? null; - if (currentEntry === null) return null; - const showInstanceBadge = shouldShowInstanceBadge( - currentEntry, - props.providerEntryByInstanceId.values(), - ); - const current = ( - - ); - if (stack.length === 1) { - return {current}; - } - return ( - - {stack.slice(0, -1).map((instanceId) => { - const entry = props.providerEntryByInstanceId.get(instanceId); - if (entry === undefined) return null; - return ( - - ); - })} - {current} - - ); -} - -function SidebarThreadTooltip({ - thread, - project, - projectDisplayName, - environmentLabel, - environmentMachine, - providerEntry, - providerEntryByInstanceId, - showInstanceBadge, - modelInstanceId, - modelLabel, - branchMismatch, - terminalStatus, - terminalProcessCount, -}: { - thread: SidebarThreadSummary; - project: ProjectFaviconProject | null; - projectDisplayName: string | null; - environmentLabel: string | null; - environmentMachine: EnvironmentMachineKind; - providerEntry: SidebarProviderEntry | null; - providerEntryByInstanceId: ReadonlyMap; - showInstanceBadge: boolean; - modelInstanceId: string; - modelLabel: string; - branchMismatch: { - threadBranch: string; - currentBranch: string; - } | null; - terminalStatus: TerminalStatusIndicator | null; - terminalProcessCount: number; -}) { - const driverKind = providerEntry?.driverKind ?? null; - const previousProviderNames = thread.providerInstanceHistory - .filter((instanceId) => instanceId !== modelInstanceId) - .map((instanceId) => providerEntryByInstanceId.get(instanceId)?.displayName ?? instanceId); - const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); - return ( - - 0 ? ( -
- -
- ) : null - } - > - {projectDisplayName ? ( -
- {project ? : null} -
{projectDisplayName}
-
- ) : null} - {environmentLabel ? ( -
- -
{environmentLabel}
-
- ) : null} - {thread.branch ? ( -
- - -
- ) : null} - {branchMismatch ? ( -
- -
- You're currently checked out on another branch. -
-
- ) : null} - {driverKind ? ( -
- -
- {showInstanceBadge && providerEntry - ? `${modelLabel} · ${providerEntry.displayName}` - : modelLabel} -
-
- ) : null} - {previousProviderNames.length > 0 ? ( -
- -
- Handed off from {previousProviderNames.join(", ")} -
-
- ) : null} - {terminalStatus ? ( -
- -
- {terminalProcessLabel(terminalProcessCount)} -
-
- ) : null} - {thread.runtime?.lastError ? ( -
- -
- {thread.runtime.lastErrorClass === "usage_limit" - ? "Usage limit reached" - : "Error occurred"} -
-
- ) : null} -
-
- ); -} - -function SnoozeMenuButton(props: { - open: boolean; - onOpenChange: (open: boolean) => void; - onSnooze: (preset: Pick) => void; - timestampFormat: TimestampFormat; -}) { - const { open, onOpenChange, onSnooze, timestampFormat } = props; - const presets = open ? resolveSnoozePresets(new Date(), timestampFormat) : []; - return ( - - - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" - /> - } - /> - } - > - - - Snooze thread - - - {presets.map((preset) => ( - { - event.stopPropagation(); - onSnooze(preset); - }} - > - {preset.label} - {preset.whenLabel} - - ))} - - { - event.stopPropagation(); - const choice = await requestCustomSnooze(); - if (choice) onSnooze(choice); - }} - > - Custom… - - - - ); -} - -type SortableThreadRowBag = Pick< - ReturnType, - "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" ->; - -// useSortable memoizes on its `disabled` object, so a fresh one per render -// re-renders every row and marker on each sidebar update. -const SIDEBAR_SORTABLE_DRAG_DISABLED = { draggable: true } as const; -const SIDEBAR_SORTABLE_DRAG_ENABLED = { draggable: false } as const; - -// Takes the row's props rather than a render function: a closure from the -// sidebar body is new on every sidebar render, which rebuilt the bag and -// re-rendered every memoized row with it. -function SortableThreadRow( - props: Omit & { - sortableId: string; - sortableDisabled: boolean; - contextDrag: boolean; - }, -) { - const { sortableId, sortableDisabled, contextDrag, ...rowProps } = props; - const canOperateThread = useEnvironmentScope( - rowProps.thread.environmentId, - AuthOrchestrationOperateScope, - ); - const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: sortableId, - disabled: - sortableDisabled || !canOperateThread - ? SIDEBAR_SORTABLE_DRAG_DISABLED - : SIDEBAR_SORTABLE_DRAG_ENABLED, - animateLayoutChanges: animateSidebarLayoutChanges, - }); - // dnd-kit memoizes each field but not the bag, so the memoized row would - // rerender on every shell update without this. - const bag = { - listeners, - setNodeRef, - transform: contextDrag ? null : transform, - transition: contextDrag && isDragging ? "transform 150ms ease-out" : transition, - isDragging: isDragging && !contextDrag, - }; - // Keyed by variant so a card/slim switch remounts the row. - return ; -} - -const draftSurfaceClassName = "bg-warning/4 hover:bg-warning/8"; -const draftPenClassName = "size-3 shrink-0 text-warning-foreground"; - -function SortableSidebarMarker(props: { - marker: SidebarListMarker; - className?: string; - children?: ReactNode; - "data-testid"?: string; -}) { - const { setNodeRef, transform, transition } = useSortable({ - id: sidebarMarkerId(props.marker), - disabled: SIDEBAR_SORTABLE_DRAG_DISABLED, - animateLayoutChanges: animateSidebarLayoutChanges, - }); - return ( -
  • - {props.children} -
  • - ); -} - -function SidebarSectionPlaceholder(props: { - marker: "active-placeholder" | "settled-placeholder"; - label: string; - showHint: boolean; - isDropTarget: boolean; -}) { - return ( - - {props.showHint ? ( -
    - {props.label} -
    - ) : null} -
    - ); -} - const SIDEBAR_DRAG_DISTANCE = 6; -type SidebarSweepAction = "settle" | "unsettle" | "unsnooze"; - const SIDEBAR_DRAG_LABEL_HEIGHT = 24; -function SidebarDragBoundary(props: { - marker: "pinned-header" | "pinned-divider"; - label: string; - visible: boolean; - isDropTarget: boolean; -}) { - return ( - - {props.visible ? ( -
    - - {props.label} - - -
    - ) : null} -
    - ); -} - -function SidebarSectionHeader(props: { - marker: "working-header" | "snoozed-header" | "settled-header"; - label: string; - className?: string; - dragging?: boolean; - isDropTarget?: boolean; - toggle: { expanded: boolean; onToggle: () => void }; -}) { - const shelf = - props.marker === "working-header" - ? "working" - : props.marker === "snoozed-header" - ? "snoozed" - : "settled"; - const snoozed = shelf === "snoozed"; - return ( - - - {props.label} - - - ); -} - -const SidebarDraftRow = memo(function SidebarDraftRow(props: { - draftId: DraftId; - composer: ComposerThreadDraftState; - project: ProjectFaviconProject | null; - projectDisplayName: string | null; - isActive: boolean; - onNavigate: (draftId: DraftId) => void; - onDiscard: (draftId: DraftId) => void; - onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; -}) { - const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props; - const promptPreview = - replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) - .trim() - .split("\n", 1)[0] ?? ""; - // images mirrors persistedAttachments once rehydration finishes; before - // that only the persisted list is populated, hence max not sum. - const attachmentCount = - Math.max(composer.images.length, composer.persistedAttachments.length) + - composer.files.length + - composer.terminalContexts.length + - composer.previewAnnotations.length + - composer.reviewComments.length; - const preview = - promptPreview.length > 0 - ? promptPreview - : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; - const accessibility = resolveSidebarRowAccessibility({ - title: preview, - statusLabel: "Unsent draft", - projectDisplayName: props.projectDisplayName, - isActive: props.isActive, - }); - const handleActivate = () => onNavigate(draftId); - const handleKeyDown = (event: ReactKeyboardEvent) => { - // Keys targeting the nested discard button belong to the button: - // preventDefault here would swallow Space's synthesized click and - // navigate instead of discarding. - if ((event.target as HTMLElement).closest("button")) return; - if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { - event.preventDefault(); - const rect = event.currentTarget.getBoundingClientRect(); - onContextMenu(draftId, { x: rect.left, y: rect.bottom }); - } else if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onNavigate(draftId); - } - }; - const handleContextMenu = (event: ReactMouseEvent) => { - event.preventDefault(); - onContextMenu(draftId, { x: event.clientX, y: event.clientY }); - }; - const handleDiscard = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onDiscard(draftId); - }; - return ( -
  • -
    - {preview} -
    -
    - - {props.project ? ( - - ) : null} - - {props.projectDisplayName} - - - - - - - } - /> - Discard draft - - -
    -
    - {preview} -
    -
    -
    -
  • - ); -}); - -interface SidebarDraftRowData { - draftId: DraftId; - session: DraftSessionState; - composer: ComposerThreadDraftState; -} - -const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { - projectByKey: ReadonlyMap; - projectDisplayNameByKey: ReadonlyMap; - scopedProjectKeys: ReadonlySet | null; - routeDraftId: string | null; - onNavigateToDraft: (draftId: DraftId) => void; - onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; -}) { - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); - const [frozenActive, setFrozenActive] = useState<{ - routeDraftId: string | null; - row: SidebarDraftRowData | null; - }>({ routeDraftId: null, row: null }); - if (frozenActive.routeDraftId !== props.routeDraftId) { - let row: SidebarDraftRowData | null = null; - if (props.routeDraftId !== null) { - const draftId = DraftId.make(props.routeDraftId); - const session = draftThreadsByThreadKey[draftId]; - const composer = draftsByThreadKey[draftId]; - row = - session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) - ? { draftId, session, composer } - : null; - } - setFrozenActive({ routeDraftId: props.routeDraftId, row }); - } - const drafts = (() => { - const rows: SidebarDraftRowData[] = []; - for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) { - if (session.promotedTo != null) { - continue; - } - if ( - props.scopedProjectKeys !== null && - !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) - ) { - continue; - } - if (draftKey === props.routeDraftId) { - if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) { - rows.push(frozenActive.row); - } - continue; - } - const composer = draftsByThreadKey[draftKey]; - if (!composer || !composerDraftHasUserContent(composer)) { - continue; - } - rows.push({ draftId: DraftId.make(draftKey), session, composer }); - } - rows.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt)); - return rows; - })(); - if (drafts.length === 0) { - return null; - } - return ( - <> - {drafts.map(({ composer, draftId, session }) => { - const projectKey = `${session.environmentId}:${session.projectId}`; - return ( - - ); - })} -
  • - - ); -}); - -const dropVerbBadge: Record = { - pin: ( - <> - - Pin - - ), - unpin: ( - <> - - Unpin - - ), - settle: ( - <> - - Settle - - ), - unsettle: ( - <> - - Un-settle - - ), - wake: ( - <> - - Wake - - ), -}; - -type SidebarThreadRowProps = { - thread: SidebarThreadSummary; - variant: "card" | "slim"; - variantAction: SidebarSweepAction; - settlementSupported: boolean; - snoozeSupported: boolean; - pinningSupported: boolean; - isPinned: boolean; - sortable?: SortableThreadRowBag | undefined; - dropVerb: SidebarDropVerb | null; - dragOverPinned: boolean; - sweepAction: SidebarSweepAction | null; - snoozeWakeLabelText: string | null; - wokeAt: string | null; - isActive: boolean; - // Unlike isActive, never true for the ancestor standing in for an open - // subagent: drafts and PR clicks care about which composer is on screen. - isRouteThread: boolean; - openPullRequestsInRightPanel: boolean; - jumpLabel: string | null; - currentEnvironmentId: string | null; - environmentLabel: string | null; - environmentMachine: EnvironmentMachineKind; - scratchMachineLabel: string | null; - project: EnvironmentProject | null; - projectDisplayName: string | null; - providerEntryByInstanceId: ReadonlyMap; - timestampFormat: TimestampFormat; - onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; - onThreadActivate: (threadRef: ScopedThreadRef) => void; - onStartRename: (threadRef: ScopedThreadRef, title: string) => void; - onRenameTitleChange: (title: string) => void; - onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; - onCancelRename: () => void; - isRenaming: boolean; - renamingTitle: string; - onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; - onSettle: (threadRef: ScopedThreadRef) => void; - onActionSweepStart: ( - threadRef: ScopedThreadRef, - action: SidebarSweepAction, - event: PointerEvent, - ) => void; - onUnsettle: (threadRef: ScopedThreadRef) => void; - onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; - onUnsnooze: (threadRef: ScopedThreadRef) => void; - onUnpin: (threadRef: ScopedThreadRef) => void; - onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; -}; - -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { - const { - isRenaming, - onCancelRename, - onCommitRename, - onContextMenu, - onAcknowledgeWoke, - onFileDropThreads, - onRenameTitleChange, - onSettle, - onActionSweepStart, - onSnooze, - onStartRename, - onThreadActivate, - onThreadClick, - onUnsettle, - onUnsnooze, - onUnpin, - openPullRequestsInRightPanel, - renamingTitle, - thread, - variant, - variantAction, - } = props; - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const threadKey = scopedThreadKey(threadRef); - const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); - useEffect(() => { - if (!canOperateThread && isRenaming) onCancelRename(); - }, [canOperateThread, isRenaming, onCancelRename]); - const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); - const isRegeneratingTitle = thread.titleRegeneration != null; - const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt); - const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); - const openPrLink = useOpenPrLink(); - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const terminalProcessCount = runningTerminalIds.length; - const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isRouteThread; - const handleDiscardDraftClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - discardComposerDraft(threadRef); - }; - - const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; - const linkedPullRequestStatus = useLinkedThreadPullRequest( - thread.environmentId, - thread.linkedPullRequest, - leaseLiveStatus, - thread.pullRequests, - thread.branchPullRequest, - ); - const gitStatus = useEnvironmentQuery( - leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd, includeRemote: false, threadId: thread.id }, // T3-CUSTOM(expbkt3): per-thread vcs status - }) - : null, - ); - const visibleGitStatus = useRetainedValue( - JSON.stringify([thread.environmentId, gitCwd]), - gitStatus.data, - ); - const pr = linkedPullRequestStatus?.pr ?? null; - const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); - const currentLinkedPr = supportsMultiplePullRequests - ? resolveThreadCurrentPullRequestLink(thread.pullRequests) - : null; - - const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); - const status = resolveSidebarThreadStatus(thread); - const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); - const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); - const isWoke = - wokeAtDate !== null && - (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - thread.settledOverride !== "settled"; - const shouldRecede = shouldRecedeSidebarThread({ - status, - isUnread, - isWoke, - isActive: props.isActive, - isSelected, - }); - // T3-CUSTOM(expbkt3): asynchronous questions never block the active run. - const asyncQuestionSuffix = thread.hasPendingAsyncUserInput ? " · Question" : ""; - const topStatus = - status === "working" - ? { - label: `${thread.goal?.status === "active" ? "Goal" : "Working"}${asyncQuestionSuffix}`, // T3-CUSTOM(expbkt3): async question suffix - icon: "working" as const, - className: "text-info", - } - : status === "waiting" - ? { - label: "Waiting", - icon: "waiting" as const, - className: "text-muted-foreground", - } - : status === "approval" - ? { - label: `Approval${asyncQuestionSuffix}`, // T3-CUSTOM(expbkt3): async question suffix - icon: "approval" as const, - className: "text-warning-foreground", - } - : status === "input" - ? { - label: `Input${asyncQuestionSuffix}`, // T3-CUSTOM(expbkt3): async question suffix - icon: "input" as const, - className: "text-indigo-600 dark:text-indigo-300", - } - : status === "limited" - ? { - label: "Limited", - icon: "failed" as const, - className: "text-warning", - } - : status === "failed" - ? { - label: "Failed", - icon: "failed" as const, - className: "text-error", - } - : isWoke - ? { - label: "Woke", - icon: "woke" as const, - className: "text-warning", - } - : isUnread - ? { - label: "Done", - icon: "done" as const, - className: "text-success", - } - : null; - const isWokeStatus = topStatus?.icon === "woke"; - - const branchMismatch = resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", - activeWorktreePath: thread.worktreePath, - activeThreadBranch: thread.branch, - currentGitBranch: visibleGitStatus?.refName ?? null, - }); - const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); - - const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const showInstanceBadge = - providerEntry !== null && - shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); - const selectedModel = providerEntry?.models.find( - (model) => model.slug === thread.modelSelection.model, - ); - const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : thread.modelSelection.model; - - const isRemote = thread.environmentId !== props.currentEnvironmentId; - const showsScratchMachine = !thread.branch && props.scratchMachineLabel !== null; - - const detailsTooltip = ( - - ); - - const handleClick = (event: ReactMouseEvent) => { - onThreadClick(event, threadRef); - }; - const handleAcknowledgeWokeClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (props.wokeAt === null) return; - onAcknowledgeWoke(threadRef, props.wokeAt); - }; - const handleContextMenu = (event: ReactMouseEvent) => { - event.preventDefault(); - onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); - }; - // T3-CUSTOM(expbkt3): same menu, reached by holding the row on a touch screen. - const longPressContextMenu = useLongPressContextMenu((position: { x: number; y: number }) => - onContextMenu(threadRef, position), - ); - const handleKeyDown = (event: ReactKeyboardEvent) => { - if (event.target !== event.currentTarget) return; - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - onThreadActivate(threadRef); - }; - const handleDoubleClick = (event: ReactMouseEvent) => { - if ( - !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) || - isRenaming || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } - if ((event.target as HTMLElement).closest("button, a, input")) return; - event.preventDefault(); - onStartRename(threadRef, thread.title); - }; - const [isFileDragOver, setIsFileDragOver] = useState(false); - const fileDropHandlers = onFileDropThreads - ? makeWorkspaceFileDropHandlers({ - setDragActive: setIsFileDragOver, - addFiles: (files) => { - onFileDropThreads(threadRef, files); - }, - addFolders: () => {}, - }) - : null; - useEffect(() => { - if (!isFileDragOver) return; - const clearFileDrag = () => setIsFileDragOver(false); - window.addEventListener("dragend", clearFileDrag); - return () => window.removeEventListener("dragend", clearFileDrag); - }, [isFileDragOver]); - const renameCommittedRef = useRef(false); - useEffect(() => { - if (isRenaming) renameCommittedRef.current = false; - }, [isRenaming]); - const handleRenameKeyDown = (event: ReactKeyboardEvent) => { - event.stopPropagation(); - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Enter") { - event.preventDefault(); - renameCommittedRef.current = true; - onCommitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - renameCommittedRef.current = true; - onCancelRename(); - } - }; - const handleRenameBlur = () => { - if (!renameCommittedRef.current) { - onCommitRename(threadRef, renamingTitle, thread.title); - } - }; - const handleSettleClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onSettle(threadRef); - }; - const handleActionPointerDown = (event: ReactPointerEvent) => { - if (!event.isPrimary || event.button !== 0) return; - event.stopPropagation(); - onActionSweepStart(threadRef, variantAction, event.nativeEvent); - }; - const handleUnsettleClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnsettle(threadRef); - }; - const handleUnsnoozeClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnsnooze(threadRef); - }; - const handleUnpinClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnpin(threadRef); - }; - const handleSnoozePreset = (preset: Pick) => { - onSnooze(threadRef, preset); - }; - const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); - // The React Compiler caches render results on their inputs, so a clock read - // here would stay frozen until `thread` changes and a queued-turn gate would - // never reopen. The gate's clock is state, reset with the thread. - const [snoozeGateClock, setSnoozeGateClock] = useState(() => ({ - thread, - now: new Date().toISOString(), - })); - if (snoozeGateClock.thread !== thread) { - setSnoozeGateClock({ thread, now: new Date().toISOString() }); - } - const snoozeGateNow = snoozeGateClock.now; - const snoozeGateExpiryMs = hasQueuedTurnStart(thread, { now: snoozeGateNow }) - ? Date.parse(thread.latestUserMessageAt ?? "") + QUEUED_TURN_START_GRACE_MS - : Number.NaN; - useEffect(() => { - // A gate still closed past its expiry is held by runtime status, not time, - // so no clock tick can open it. - if (!Number.isFinite(snoozeGateExpiryMs) || snoozeGateExpiryMs <= Date.parse(snoozeGateNow)) { - return; - } - const id = window.setTimeout( - () => setSnoozeGateClock({ thread, now: new Date().toISOString() }), - Math.min(Math.max(0, snoozeGateExpiryMs - Date.now()) + 50, 2_147_483_647), - ); - return () => window.clearTimeout(id); - }, [snoozeGateExpiryMs, snoozeGateNow, thread]); - const showSnoozeButton = - canOperateThread && props.snoozeSupported && canSnooze(thread, { now: snoozeGateNow }); - // Closing here, not just masking, keeps the menu from reopening when the button returns. - if (snoozeMenuOpenRaw && !showSnoozeButton) setSnoozeMenuOpen(false); - const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; - const handlePrClick = (event: ReactMouseEvent, targetUrl?: string) => { - const url = targetUrl ?? pr?.url ?? currentLinkedPr?.url; - if (!url) return; - const showInRightPanel = targetUrl !== undefined || openPullRequestsInRightPanel; - const openedInRightPanel = openPrLink(event, url, showInRightPanel ? threadRef : undefined); - if (openedInRightPanel && showInRightPanel && !props.isRouteThread) { - onThreadActivate(threadRef); - } - }; - - const rowSurfaceClassName = cn( - "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", - variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", - props.isActive - ? "bg-sidebar-row-active text-sidebar-foreground" - : isSelected || props.sweepAction !== null - ? "bg-sidebar-row-selected text-sidebar-foreground" - : hasUnsentDraft - ? cn(draftSurfaceClassName, "text-sidebar-foreground") - : shouldRecede - ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" - : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", - shouldRecede && - status === "working" && - "opacity-70 transition-opacity hover:opacity-100 focus-within:opacity-100 motion-reduce:transition-none", - isFileDragOver && "ring-1 ring-inset ring-primary/70", - isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover", - props.sortable?.isDragging && - "bg-sidebar bg-linear-to-b from-sidebar-row-active to-sidebar-row-active text-sidebar-foreground opacity-100 shadow-lg", - ); - const sortable = props.sortable; - const sortableRootProps = sortable - ? { - ref: sortable.setNodeRef, - style: { - transform: CSS.Translate.toString(sortable.transform), - transition: sortable.transition, - // A zero-height boundary also makes dnd-kit scale the source to - // zero. Only projected peers use scaleY as a visibility sentinel. - visibility: - !sortable.isDragging && sortable.transform?.scaleY === 0 - ? ("hidden" as const) - : undefined, - }, - ...sortable.listeners, - } - : {}; - const destinationVerb = sortable?.isDragging - ? props.dropVerb - : props.sweepAction === "unsnooze" - ? "wake" - : props.sweepAction; - const dragDestination = - destinationVerb !== null ? ( - - {dropVerbBadge[destinationVerb]} - - ) : null; - - const accessibility = resolveSidebarRowAccessibility({ - title: thread.title, - statusLabel: topStatus?.label ?? null, - projectDisplayName: props.projectDisplayName, - isActive: props.isActive, - }); - - const title = - isRenaming && canOperateThread ? ( - onRenameTitleChange(event.target.value)} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - onBlur={handleRenameBlur} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" - /> - ) : ( - - {thread.title} - - ); - const accessibleTitle = isRenaming ? null : {thread.title}; - - const prBadgeShape = supportsMultiplePullRequests - ? resolveThreadPullRequestBadge(thread.pullRequests) - : null; - const handlePrListClick = () => { - useRightPanelStore.getState().open(threadRef, "pull-requests"); - if (!props.isRouteThread) onThreadActivate(threadRef); - }; - const prBadge = - prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( - } - badge={prBadgeShape} - pullRequests={thread.pullRequests} - number={pr?.number ?? currentLinkedPr?.number} - url={pr?.url ?? currentLinkedPr?.url} - status={prStatus} - onOpenList={handlePrListClick} - onOpenPullRequest={handlePrClick} - /> - ) : null; - const terminalStatusIcon = ( - - ); - const draftIndicator = hasUnsentDraft ? ( - - - } - > - - - Unsent draft - - ) : null; - const showPin = - props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); - const pinIndicator = showPin ? ( - props.pinningSupported && canOperateThread && !sortable?.isDragging ? ( - - - } - > - - - - Unpin thread - - ) : ( - - ) - ) : null; - - if (variant === "slim") { - return ( -
  • - - - } - > - {accessibleTitle} - - {props.project ? : null} - - {draftIndicator} - {title} - {pinIndicator} - {terminalStatusIcon} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {prBadge} - {sortable?.isDragging ? ( - dragDestination - ) : ( - - - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - - {props.snoozeWakeLabelText} - - ) : isWoke ? ( - - - - Woke - - } - /> - Dismiss Woke notification - - ) : ( - - - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - )} - - )} - {props.sweepAction !== null ? dragDestination : null} - {props.jumpLabel ? : null} - - {detailsTooltip} - -
  • - ); - } - - const diff = latestRunDiff(thread); - - return ( -
  • - - - } - > - {accessibleTitle} -
    -
    - {draftIndicator} - {props.project ? ( - - ) : null} - {/* Left of the status slot, the pin would slide when hover swaps the - label for the wider row controls. */} - {pinIndicator} - {props.projectDisplayName ? ( - - {props.projectDisplayName} - - ) : ( - - )} - {sortable?.isDragging ? ( - dragDestination - ) : ( - - - {topStatus ? ( - isWokeStatus ? ( - - - - {topStatus.label} - - } - /> - Dismiss Woke notification - - ) : ( - - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "waiting" ? ( - - ) : topStatus.icon === "input" ? ( - - ) : topStatus.icon === "approval" ? ( - - ) : topStatus.icon === "failed" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( - - - - ) : status === "waiting" ? ( - - - - ) : null} - - ) - ) : ( - - )} - - {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? ( - - {hasUnsentDraft ? ( - - - } - > - - - Discard draft - - ) : null} - {showSnoozeButton ? ( - - ) : null} - {props.settlementSupported ? ( - - - } - > - - Settle - - Settle thread - - ) : null} - - ) : null} - - )} - {props.sweepAction !== null ? dragDestination : null} -
    -
    - {title} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} -
    -
    - {thread.branch ? ( - <> - - - - - - ) : showsScratchMachine ? ( - <> - - - {props.scratchMachineLabel} - - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - - +{formatDiffCount(diff.insertions)} - {" "} - - −{formatDiffCount(diff.deletions)} - - - ) : null} - - {isRemote && !showsScratchMachine ? ( - - - - ) : null} - - -
    -
    - {props.jumpLabel ? : null} -
    - {detailsTooltip} -
    -
  • - ); -}); - -function latestRunDiff( - thread: SidebarThreadSummary, -): { insertions: number; deletions: number } | null { - void thread; - return null; -} - -const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { - isSettled: boolean; - thread: SidebarThreadSummary; - project: EnvironmentProject | null; - projectDisplayName: string | null; - environmentLabel: string | null; - environmentMachine: EnvironmentMachineKind; - providerEntryByInstanceId: ReadonlyMap; - isHighlighted: boolean; - isRouteActive: boolean; - resultId: string; - searchMatch: EnvironmentThreadSearchMatch | null; - searchQuery: string; - onHighlight: () => void; - onSelect: () => void; - onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; -}) { - const { thread } = props; - const accessibility = resolveSidebarRowAccessibility({ - title: thread.title, - statusLabel: null, - projectDisplayName: props.projectDisplayName, - isActive: props.isRouteActive, - }); - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( - props.isHighlighted || props.isRouteActive, - ); - const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; - const gitStatus = useEnvironmentQuery( - leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd, includeRemote: false }, - }) - : null, - ); - const visibleGitStatus = useRetainedValue( - JSON.stringify([thread.environmentId, gitCwd]), - gitStatus.data, - ); - const branchMismatch = resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", - activeWorktreePath: thread.worktreePath, - activeThreadBranch: thread.branch, - currentGitBranch: visibleGitStatus?.refName ?? null, - }); - const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const showInstanceBadge = - providerEntry !== null && - shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); - const selectedModel = providerEntry?.models.find( - (model) => model.slug === thread.modelSelection.model, - ); - const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : thread.modelSelection.model; - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const [isFileDragOver, setIsFileDragOver] = useState(false); - const onFileDropThreads = props.onFileDropThreads; - const fileDropHandlers = makeWorkspaceFileDropHandlers({ - setDragActive: setIsFileDragOver, - addFiles: (files) => { - onFileDropThreads(threadRef, files); - }, - addFolders: () => {}, - }); - useEffect(() => { - if (!isFileDragOver) return; - const clearFileDrag = () => setIsFileDragOver(false); - window.addEventListener("dragend", clearFileDrag); - return () => window.removeEventListener("dragend", clearFileDrag); - }, [isFileDragOver]); - return ( -
  • - - - } - > - {props.project ? ( - - ) : null} - - - - {thread.title} - - - - - - - - - {props.searchMatch ? ( - - ) : null} - - - - -
  • - ); -}); - export default function Sidebar() { const { projects, diff --git a/apps/web/src/components/UsageToggleShortcut.browser.test.tsx b/apps/web/src/components/UsageToggleShortcut.browser.test.tsx new file mode 100644 index 0000000000000..1efaa2837b008 --- /dev/null +++ b/apps/web/src/components/UsageToggleShortcut.browser.test.tsx @@ -0,0 +1,52 @@ +import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; + +const state = vi.hoisted(() => ({ navigate: vi.fn() })); + +// One key toggles Usage: it opens Usage elsewhere and goes back from it. +const keybindings = compileResolvedKeybindingsConfig([ + { key: "mod+u", command: "navigation.back", when: "usagePageOpen" }, + { key: "mod+u", command: "usage.open", when: "!usagePageOpen" }, +]); + +vi.mock(import("@effect/atom-react"), async (original) => ({ + ...(await original()), + useAtomValue: (() => keybindings) as never, +})); +vi.mock(import("@tanstack/react-router"), async (original) => ({ + ...(await original()), + useNavigate: (() => state.navigate) as never, +})); + +import { NavigationHistoryShortcuts } from "./AppSidebarLayout"; +import { CommandPalette } from "./CommandPalette"; + +// regression: #21357 +describe("usage toggle shortcut on Usage", () => { + it("goes back", async () => { + vi.clearAllMocks(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using _screen = await render(, { router: "/usage" }); + + using back = vi.spyOn(window.history, "back").mockImplementation(() => undefined); + await userEvent.keyboard("{Control>}u{/Control}"); + + expect(back).toHaveBeenCalledOnce(); + }); + + // Both listeners sit on window, so the palette must not claim the key whatever their order. + it("does not reopen Usage from the command palette", async () => { + vi.clearAllMocks(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using _screen = await render({null}, { + router: "/usage", + }); + + await userEvent.keyboard("{Control>}u{/Control}"); + + expect(state.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx index 8dc91214b50a0..deee8d7afa69d 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx @@ -6,8 +6,10 @@ import { } from "@t3tools/contracts"; import { useState, type ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; +import "~/index.css"; import { act, render } from "../../../test/render"; const mocks = vi.hoisted(() => ({ observeSource: vi.fn(), dispose: vi.fn() })); @@ -125,3 +127,51 @@ describe("citation comment source disappearance", () => { expect(textarea()).toBeNull(); }); }); + +describe("citation comment popup position", () => { + it("stays on screen when the cited text ends in a code block's hidden overflow", async () => { + using _source = setup(); + const message = document.createElement("div"); + message.className = "chat-markdown"; + message.style.width = "240px"; + const pre = document.createElement("pre"); + const code = document.createElement("code"); + // Highlighted code is split into token spans, so the range's last client rect + // covers only the final token, wholly inside the hidden overflow. + for (let index = 0; index < 40; index += 1) { + const token = document.createElement("span"); + token.textContent = `token${index} `; + code.append(token); + } + pre.append(code); + message.append(pre); + document.body.prepend(message); + using _message = { [Symbol.dispose]: () => message.remove() }; + const range = document.createRange(); + range.selectNodeContents(code); + const rects = range.getClientRects(); + expect(rects.item(rects.length - 1)!.left).toBeGreaterThan(window.innerWidth); + const sourceAnchor = { + source: message, + range, + viewport: document.body, + } as AssistantCitationSourceAnchor; + + await using _screen = await mount( + sourceAnchor, + vi.fn(() => true), + ); + const popup = () => document.querySelector('[data-slot="popover-popup"]'); + await vi.waitFor(() => expect(popup()?.getBoundingClientRect().width).toBeGreaterThan(0)); + // Typing scrolls ancestors to reveal the caret, which shifts the whole app + // sideways when the box is off screen. + await userEvent.keyboard("note"); + + expect(textarea()?.value).toBe("note"); + expect(document.scrollingElement?.scrollLeft).toBe(0); + expect(document.body.scrollLeft).toBe(0); + const rect = popup()!.getBoundingClientRect(); + expect(rect.left).toBeGreaterThanOrEqual(0); + expect(rect.right).toBeLessThanOrEqual(window.innerWidth); + }); +}); diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 741880d411614..dd21f7c466844 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -94,7 +94,15 @@ export function AssistantCitationChip({ contextElement: activeSourceAnchor.source, getBoundingClientRect: () => { const rects = activeSourceAnchor.range.getClientRects(); - return rects.item(rects.length - 1) ?? activeSourceAnchor.range.getBoundingClientRect(); + const rect = + rects.item(rects.length - 1) ?? activeSourceAnchor.range.getBoundingClientRect(); + // Code blocks and tables scroll sideways inside the message, so the cited + // text can end in their hidden overflow. Base UI only shifts the popup while + // it still touches the anchor, so clamp the anchor to the message's width. + const bounds = activeSourceAnchor.source.getBoundingClientRect(); + const left = Math.min(Math.max(rect.left, bounds.left), bounds.right); + const right = Math.max(Math.min(rect.right, bounds.right), left); + return new DOMRect(left, rect.top, right - left, rect.height); }, } : undefined; diff --git a/apps/web/src/components/chat/ChatBannerLane.tsx b/apps/web/src/components/chat/ChatBannerLane.tsx new file mode 100644 index 0000000000000..7a7d8af41c83e --- /dev/null +++ b/apps/web/src/components/chat/ChatBannerLane.tsx @@ -0,0 +1,64 @@ +import type { + OrchestrationV2ProviderFailureClass, + ProviderInstanceId, + ServerProvider, + ThreadId, +} from "@t3tools/contracts"; +// T3-CUSTOM(expbkt3): ReactNode types the fork banners rendered in the lane. +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import { ProviderStatusBanner } from "./ProviderStatusBanner"; +import { dismissThreadErrorBannerForSession, ThreadErrorBanner } from "./ThreadErrorBanner"; + +/** + * The banners pinned above the timeline. The dismiss handlers are built here from plain + * values, so a ChatView render that changes neither banner leaves both cached. + */ +export function ChatBannerLane(props: { + providerStatus: ServerProvider | null; + providerStatusBannerKey: string | null; + setDismissedProviderStatusBannerKey: Dispatch>; + onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; + threadId: ThreadId; + threadError: string | null; + threadErrorClass: OrchestrationV2ProviderFailureClass | null; + threadErrorBannerKey: string | null; + setThreadError: (threadId: ThreadId | null, error: string | null) => void; + setThreadErrorBannerDismissTick: Dispatch>; + // T3-CUSTOM(expbkt3): fork banners (durable outbox recovery) share the lane. + children?: ReactNode; +}) { + const { + providerStatus, + providerStatusBannerKey, + setDismissedProviderStatusBannerKey, + onOpenProviderSetup, + threadId, + threadError, + threadErrorClass, + threadErrorBannerKey, + setThreadError, + setThreadErrorBannerDismissTick, + // T3-CUSTOM(expbkt3): fork banners share the lane. + children, + } = props; + return ( +
    + setDismissedProviderStatusBannerKey(providerStatusBannerKey)} + onOpenProviderSetup={onOpenProviderSetup} + /> + { + setThreadError(threadId, null); + dismissThreadErrorBannerForSession(threadErrorBannerKey); + setThreadErrorBannerDismissTick((tick) => tick + 1); + }} + /> + {/* T3-CUSTOM(expbkt3): fork banners share the lane. */} + {children} +
    + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7b9a1be666102..9430f801be9cb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,4 +1,3 @@ -import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import { useComposerTypingGuard } from "./useComposerTypingGuard"; import { insertAttachmentReferencesForDraft } from "./composerAttachmentReferences"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; @@ -6,21 +5,11 @@ import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtime import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useRightPanelStore } from "~/rightPanelStore"; -import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; -import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; -import { - composerPullRequestsFromLinks, - filterComposerPullRequestMatches, -} from "@t3tools/shared/composerPullRequestMatches"; import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; -import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; -import { RefreshIcon } from "~/components/ui/refresh-icon"; import { questionAttachmentDraftId, - countQuestionAttachments, trackOpenQuestionAttachmentDraft, useQuestionAttachmentPreparation, - changeQuestionAttachmentPreparation, } from "../../questionAttachments"; import type { KeybindingCommand, @@ -30,7 +19,6 @@ import type { EnvironmentId, ModelSelection, ProjectId, - PullRequestListInput, PreviewAnnotationPayload, ProviderApprovalDecision, ThreadContextRecord, @@ -41,7 +29,6 @@ import type { ScopedThreadRef, ServerProvider, ThreadId, - SnapShotSource, } from "@t3tools/contracts"; import { EnvironmentRequestInvalidError, @@ -49,19 +36,11 @@ import { ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, - PROVIDER_SEND_TURN_MAX_INPUT_CHARS, - PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Schema from "effect/Schema"; -import { - isPasteAsTextShortcut, - nextPastedTextFileName, - pastedTextDisposition, - wouldTextPasteExceedLimit, -} from "@t3tools/client-runtime/text-paste"; +import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { splitTransferredFiles } from "./workspaceFileDrop"; @@ -69,7 +48,6 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model" import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { memo, - type ComponentProps, type ReactNode, useEffect, useEffectEvent, @@ -78,7 +56,6 @@ import { useLayoutEffect, useRef, useState, - useSyncExternalStore, } from "react"; import { createPortal, flushSync } from "react-dom"; import { @@ -87,22 +64,20 @@ import { type ComposerSubmissionIntent, collapseExpandedComposerCursor, composerRequiresModifier, - composerSubmissionIntentForKey, composerStateAtPromptEnd, detectComposerTrigger, expandCollapsedComposerCursor, formatAssistantCitationForComposer, isPathSuggestionCurrent, + extendReplacementRangeForTrailingSpace, replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; -import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation"; import { deriveComposerSendState, getAntigravitySendBlockReason, readFileAsDataUrl, resolveComposerInteractionMode, - resolveComposerProviderSelection, threadShellHasStarted, } from "../ChatView.logic"; // T3-CUSTOM(expbkt3): review comments on agent messages. @@ -119,8 +94,6 @@ import { makeComposerMentionDragHandlers, } from "./composerMentionDrag"; import { - composerFloatingLayerProps, - useComposerMenuProps, isInsideCollapsedComposerControls, isInsideRestingComposerControlScope, } from "./composerEventScope"; @@ -128,29 +101,17 @@ import { type ComposerFileAttachment, type ComposerImageAttachment, type DraftId, - type PersistedComposerFileAttachment, type PersistedComposerImageAttachment, - composerFileDedupKey, - composerFileMatchesReattachMarker, composerFileNeedsReattach, composerDraftHasUserContent, composerTargetKey, - hydrateImagesFromPersisted, useComposerDraftStore, useComposerThreadDraft, - deriveEffectiveComposerModelState, - useComposerDraftModelState, } from "../../composerDraftStore"; -import { - MAX_STASH_ENTRIES, - partitionStashAttachments, - usePromptStashStore, - type PromptStashEntry, -} from "../../promptStashStore"; import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; -import { useComposerMenuState } from "./useComposerMenuState"; import { useComposerTriggerState } from "./useComposerTriggerState"; +import { useComposerMenuItems } from "./useComposerMenuItems"; import { useComposerFocusState } from "./useComposerFocusState"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; import { @@ -180,61 +141,33 @@ import { type ComposerBannerStackContent, type ComposerBannerStackItem, } from "./ComposerBannerStack"; -import { - compressImageForStash, - isHeicImageFile, - prepareImageForAttachment, -} from "../../lib/imageCompression"; -import { AttachmentImage } from "../media/AttachmentImage"; -import { - fileAttachmentTooLargeMessage, - formatAttachmentSize, -} from "@t3tools/client-runtime/state/attachments"; import { attachmentsToReleaseOnUploadCapabilityLoss, composerOtherFilesForPresentation, - classifyComposerAttachmentFile, fileAttachmentCapabilityBlockReason, fileAttachmentStagingLimit, isPreviewableComposerVideo, - normalizeComposerImageFileMimeType, shouldHandleComposerAttachmentPaste, } from "./composerAttachmentFiles"; import { - readAttachmentUpload, releaseAttachmentUpload, releaseDraftAttachment, - releasePersistedAttachmentUpload, - retryAttachmentUpload, startAttachmentUpload, useAttachmentUploadStore, - verifyStashedAttachmentUpload, } from "../../lib/attachmentUploadQueue"; -import { - attachmentUploadBlockReason, - formatAttachmentUploadProgress, -} from "../../lib/attachmentUploadState"; -import { isCommandPaletteOpen } from "../../commandPaletteBus"; -import { getTerminalFocusOwner } from "../../lib/terminalFocus"; +import { attachmentUploadBlockReason } from "../../lib/attachmentUploadState"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; -import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings"; +import { shortcutLabelForCommand } from "../../keybindings"; import { type TerminalContextDraft, type TerminalContextSelection, } from "../../lib/terminalContext"; +import { toKindScopedComposerContextId } from "@t3tools/shared/composerContextReferences"; import { - replaceComposerContextReferences, - toKindScopedComposerContextId, -} from "@t3tools/shared/composerContextReferences"; -import { - getRestingComposerImagePreviewCounts, - resolveRestingComposerControlsLayout, - shouldAnimateComposerRestingTransition, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, shouldUseRestingComposerLayout, } from "../composerFooterLayout"; -import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; import { type ComposerCitationCommentRequest, type ComposerPromptEditorHandle, @@ -243,12 +176,10 @@ import { import { ComposerContextActionsContext, composerContextRecordsFromDraft, - uploadedContextRecordFromDraft, } from "../composerContextPresentation"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { collectInlineContextIds, - stripInlineContextReferences, type ComposerContextReference, ensureInlineContextReferences, formatInlineContextReference, @@ -256,117 +187,58 @@ import { inlineContextReferenceReplacement, } from "~/lib/composerContextReferences"; import { - asKnownContextRecord, attachmentContextRecord, - composerContextImportLookupIds, - isSameComposerContextPayload, uploadedAttachmentContextRecord, - fileContextReference, - imageContextReference, previewAnnotationContextId, previewAnnotationContextRecord, - previewAnnotationFromRecord, reviewCommentContextId, reviewCommentContextReference, reviewCommentContextRecord, - reviewCommentFromRecord, - terminalContextDraftFromRecord, terminalContextReference, terminalContextRecord, threadContextRecord, threadContextReference, } from "~/lib/composerContextRecords"; -import { - copiedComposerAttachmentFile, - rememberCopiedComposerAttachments, -} from "~/lib/copiedComposerAttachments"; -import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; +import { rememberCopiedComposerAttachments } from "~/lib/copiedComposerAttachments"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; -import { readThreadShell, useThreadShells } from "~/state/entities"; -import { requestConfirmDialog } from "~/confirmDialog"; +import { readThreadShell } from "~/state/entities"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; -import type { - ComposerContextClipboardFragment, - ComposerContextRecord, - ThreadPullRequestLink, -} from "@t3tools/contracts"; -import { resolveAssetUrl } from "~/assets/assetUrls"; -import { assetEnvironment } from "~/state/assets"; -import { readPreparedConnection } from "~/state/session"; -import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; -import { orchestrationEnvironment } from "~/state/orchestration"; +import type { ComposerContextRecord, ThreadPullRequestLink } from "@t3tools/contracts"; import { threadContextAttachment } from "~/lib/threadContextAttachment"; -import { - pullRequestEnvironment, - usePullRequestList, - type EnvironmentQueryTarget, -} from "~/state/pullRequests"; -import { useEnvironmentQuery } from "~/state/query"; -import { useComposerPathSearch, useDebouncedValue } from "~/state/queries"; -import { ProviderModelPicker } from "./ProviderModelPicker"; -import { resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { type ComposerCommandItem, ComposerCommandMenu, composerSuggestionOptionId, } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; -import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; -import { ComposerImageThumbnail } from "./ComposerImageThumbnail"; +import { + ComposerAttachButton, + ComposerAttachmentFileList, + ComposerAttachmentMediaRow, + ComposerFilePreviewDialog, + ComposerQueuedAttachmentRow, + ComposerRestingImagePreviews, +} from "./ComposerAttachmentStrip"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; +import { ComposerFooterPrimaryActions } from "./ComposerFooterControls"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerQueuedMessages, type ComposerQueuedMessage } from "./ComposerQueuedMessages"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; -import { - ComposerControl, - ComposerControlIcon, - ComposerControlSeparator, - ComposerSelectControl, -} from "./ComposerControl"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDetail.logic"; -import { - matchesPullRequestQuery, - rankPullRequestMatches, -} from "../pullRequest/pullRequestList.logic"; -import { - searchSlashCommandItems, - slashCommandItemsForPromptPosition, -} from "./composerSlashCommandSearch"; -import { - getComposerPromptInjectionState, - getComposerProviderState, - getComposerReasoningStep, - renderProviderTraitsMenuContent, - renderProviderTraitsPicker, -} from "./composerProviderState"; -import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter"; +import { getComposerPromptInjectionState, getComposerReasoningStep } from "./composerProviderState"; +import { readPromptUltrathink } from "./TraitsPicker"; import { providerSupportsManualCompaction, resolveContextWindowModelDisplayName, shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; import { - attachVideoThumbnail, buildAttachmentVideoPreview, buildExpandedImagePreview, type ExpandedImagePreview, } from "./ExpandedImagePreview"; -import { - SNAP_SHOT_ATTACHMENT_FRAME_CLASS, - SnapShotAttachmentDetails, -} from "./SnapShotAttachmentDetails"; -import { - getPendingSnapShotAnimations, - pendingSnapShotAnimationIdsForTarget, - scheduleSnapShotAnimationDestination, - setSnapShotAnimationDestination, - shouldAnimateSnapShotArrival, - subscribeToPendingSnapShotAnimations, -} from "../../lib/snapShotAnimation"; -import { resizeSnapShotSource } from "../../lib/snapShotSource"; -import { basenameOfPath } from "../../pierre-icons"; import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { getComposerPromptLengthValidationMessage, @@ -374,12 +246,8 @@ import { submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; -import { PierreEntryIcon } from "./PierreEntryIcon"; import { pendingDraftWork } from "./pendingDraftWork"; -import { - importComposerThreadAttachment, - remainingComposerAttachmentSlots, -} from "./composerThreadImport"; +import { importComposerThreadAttachment } from "./composerThreadImport"; import { isTimelineScrollTarget } from "./timelineScrollTarget"; import { createComposerScrollGestureState, @@ -388,708 +256,16 @@ import { resetComposerScrollGesture, suppressActiveComposerScrollGesture, } from "./composerScrollGesture"; -import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; import { observeResize } from "~/lib/observeResize"; -function ComposerVideoThumbnail({ file }: { file: File }) { - const setVideo = (video: HTMLVideoElement | null) => { - if (!video) return; - return attachVideoThumbnail(video, file); - }; - - return ( -