diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index 6a7fc4a6d5b5..d7ebb1a0790c 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 000000000000..afe6f6133936 --- /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 000000000000..a249653350f2 --- /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 b356eb0ab6ed..b229e7146510 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,14 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + # electron's install.js (run by ensure:electron) reads this cache before it + # downloads from GitHub. The key changes only with the Electron version. + - name: Cache Electron download + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} + - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -132,6 +140,14 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + # electron's install.js (run by ensure:electron) reads this cache before it + # downloads from GitHub. The key changes only with the Electron version. + - name: Cache Electron download + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} + - name: Ensure Electron runtime is installed id: electron run: vp run --filter @t3tools/desktop ensure:electron @@ -222,6 +238,14 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + # electron's install.js (run by ensure:electron) reads this cache before it + # downloads from GitHub. The key changes only with the Electron version. + - name: Cache Electron download + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} + - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -229,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/release.yml b/.github/workflows/release.yml index a2842a01f93b..c2d8da3208f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1088,7 +1088,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 \ @@ -1107,6 +1107,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" @@ -1235,7 +1242,7 @@ jobs: )" export VERCEL_PROJECT_ID - deployment_url="$( + deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --prod \ @@ -1244,6 +1251,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 c1bb8ba01eff..406fe913fe09 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 000000000000..ff75832369d8 --- /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 000000000000..e3576c995f40 --- /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 ed3bcf87c8ff..0be2912ff6ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,24 +78,30 @@ 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. - To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential). - Stop what you started, by the PID you tracked. See rule 1. +- Need a second checkout? `git worktree add` it next to this worktree, not in `/tmp`. Remove it when done. ## Test data An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- 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, targeted lint and typecheck for the scope you changed. +- 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. - Backend behavior changes ship with focused tests for that behavior. @@ -138,7 +144,7 @@ For authorized mobile verification, a missing or outdated native client is a bui - UI changes need before/after images. Motion or timing needs a short video. - Upload PR evidence to GitHub. Never commit PR-only screenshots or assets such as `.github/pr-assets/`. - One request is one PR. Split it only when the maintainer asks. Outside contributions follow the stricter [one problem per PR](CONTRIBUTING.md#one-problem) rule. -- When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. +- When babysitting: call `watch_pull_request` and end your turn. T3 Code wakes you on checks, comments, or conflicts. Do not poll or sleep. Verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. ## Documentation @@ -171,7 +177,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 10e383a7bacc..0990c0f52b31 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,6 @@ "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", "dev:stop": "node scripts/stop-dev-electron.mjs", - "typecheck": "tsc-rs --noEmit", "test": "vp test run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" }, diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index ae6cd05961b6..933a763dfa35 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -116,7 +116,26 @@ function runChecked(command, args) { ); } -function installElectronRuntime(electronDir, version) { +// electron's own installer goes through @electron/get, which reuses its +// download cache (~/.cache/electron, ~/Library/Caches/electron) and verifies +// the bundled checksums. pnpm 11 never runs it because electron has no +// install script, so a new worktree would otherwise download 130 MB again. +function installFromElectronCache(electronDir) { + const result = NodeChildProcess.spawnSync( + process.execPath, + [NodePath.join(electronDir, "install.js")], + { stdio: ["ignore", "inherit", "inherit"] }, + ); + if (result.status === 0) { + return true; + } + process.stderr.write( + `electron install.js failed with exit code ${result.status ?? "unknown"}, downloading with curl instead\n`, + ); + return false; +} + +function downloadElectronRuntime(electronDir, version) { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-electron-")); const zipPath = NodePath.join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`); @@ -149,21 +168,27 @@ function installElectronRuntime(electronDir, version) { } } +function resetRuntime(electronDir) { + NodeFS.rmSync(NodePath.join(electronDir, "dist"), { recursive: true, force: true }); + NodeFS.rmSync(NodePath.join(electronDir, "path.txt"), { force: true }); +} + export function ensureElectronRuntime() { const electronPackageJsonPath = require.resolve("electron/package.json"); const electronPackageJson = JSON.parse(NodeFS.readFileSync(electronPackageJsonPath, "utf8")); const electronDir = NodePath.dirname(electronPackageJsonPath); const platformPath = getPlatformPath(); const electronPath = NodePath.join(electronDir, "dist", platformPath); - const missingBeforeInstall = missingRuntimePaths(electronDir, platformPath); - const invalidBeforeInstall = invalidRuntimePaths(electronDir, platformPath); - - if (missingBeforeInstall.length > 0 || invalidBeforeInstall.length > 0) { - if (NodeFS.existsSync(NodePath.join(electronDir, "dist"))) { - NodeFS.rmSync(NodePath.join(electronDir, "dist"), { recursive: true, force: true }); + const needsInstall = () => + missingRuntimePaths(electronDir, platformPath).length > 0 || + invalidRuntimePaths(electronDir, platformPath).length > 0; + + if (needsInstall()) { + resetRuntime(electronDir); + if (!installFromElectronCache(electronDir) || needsInstall()) { + resetRuntime(electronDir); + downloadElectronRuntime(electronDir, electronPackageJson.version); } - NodeFS.rmSync(NodePath.join(electronDir, "path.txt"), { force: true }); - installElectronRuntime(electronDir, electronPackageJson.version); } const missingAfterInstall = missingRuntimePaths(electronDir, platformPath); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 893d6f8e076f..c0a77e9a48b7 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -43,6 +43,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: [ @@ -145,6 +150,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 5ba3da4fba10..15a8a8f91633 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 e36e91bb7482..d801e3db2d9f 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 000000000000..f927357c438d --- /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/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index f0132833c4fa..1df4dcab7e49 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -178,7 +178,11 @@ export interface ThreadComposerProps { readonly onStopThread: () => void; /** Present while the thread's queue is held. */ readonly onResumeQueue: (() => Promise) | null; - readonly onSendMessage: (followUp?: ActiveTurnComposerAction) => Promise; + /** `stillSendable` reports whether the draft is unchanged after any wait before sending. */ + readonly onSendMessage: ( + followUp?: ActiveTurnComposerAction, + stillSendable?: () => boolean, + ) => Promise; readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly canSwitchProvider: boolean; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; @@ -356,6 +360,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); const pendingPastedTextAttachmentCountRef = useRef(0); + const pendingNativeImagePasteCountRef = useRef(0); const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, @@ -599,7 +604,12 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps setComposerEditing(false); }; const handleSend = async (followUp?: ActiveTurnComposerAction) => { - if (isSubmissionLocked || pendingPastedTextAttachmentCountRef.current > 0) return; + if ( + isSubmissionLocked || + pendingPastedTextAttachmentCountRef.current > 0 || + pendingNativeImagePasteCountRef.current > 0 + ) + return; if (usageLimitsOffered && isUsageLimitsCommand(draftMessage) && draftAttachments.length === 0) { if (openUsageLimits()) onChangeDraftMessage(""); return; @@ -607,7 +617,20 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); - const sent = await settlePromise(() => onSendMessage(followUp)); + const draftAtSend = getComposerDraftSnapshot(composerDraftKey); + const stillSendable = () => { + const draft = getComposerDraftSnapshot(composerDraftKey); + return ( + pendingPastedTextAttachmentCountRef.current === 0 && + pendingNativeImagePasteCountRef.current === 0 && + draft.text === draftAtSend.text && + draft.attachments.length === draftAtSend.attachments.length && + draft.attachments.every( + (attachment, index) => attachment.id === draftAtSend.attachments[index]?.id, + ) + ); + }; + const sent = await settlePromise(() => onSendMessage(followUp, stillSendable)); inFlightThreadIdsRef.current.delete(threadKey); if (AsyncResult.isFailure(sent)) { // The composer state already surfaces send errors; only the lock needed releasing. @@ -915,7 +938,11 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps }} onSelectionChange={composerMenu.onSelectionChange} onPasteImages={(uris) => { - if (!isSubmissionLocked) void props.onNativePasteImages(uris); + if (isSubmissionLocked) return; + pendingNativeImagePasteCountRef.current += 1; + void props.onNativePasteImages(uris).finally(() => { + pendingNativeImagePasteCountRef.current -= 1; + }); }} onPasteText={(paste) => { if (isSubmissionLocked) return; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 85e8dd96183a..9741e4644441 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -39,7 +39,6 @@ import type { EnvironmentId, MessageId, ModelSelection, - ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, RuntimeRequestId, @@ -52,7 +51,11 @@ import { appendCodexArtifactTemplateUsePrompt, type CodexArtifactTemplate, } from "@t3tools/shared/codexArtifactTemplates"; -import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; +import { + declinePendingApprovals, + type ApprovalResponder, + type ThreadUserInputQuestion, +} from "@t3tools/client-runtime/state/thread-requests"; import { presentPendingBackgroundWork, presentProviderGoal, @@ -199,6 +202,7 @@ export interface ThreadDetailScreenProps { | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void } | null; readonly activePendingApproval: PendingApproval | null; + readonly activePendingApprovals: ReadonlyArray; readonly respondingApprovalId: RuntimeRequestId | null; readonly activePendingUserInput: PendingUserInput | null; readonly activePendingUserInputDrafts: Record; @@ -236,16 +240,16 @@ export interface ThreadDetailScreenProps { readonly onStopThread: () => void; /** Present while the thread's queue is held. */ readonly onResumeQueue: (() => Promise) | null; - readonly onSendMessage: (followUp?: ActiveTurnComposerAction) => Promise; + readonly onSendMessage: ( + followUp?: ActiveTurnComposerAction, + beforeDispatch?: () => Promise, + ) => Promise; readonly onReconnectEnvironment: () => void; readonly canSwitchThreadProvider: boolean; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void; - readonly onRespondToApproval: ( - requestId: RuntimeRequestId, - decision: ProviderApprovalDecision, - ) => Promise; + readonly onRespondToApproval: ApprovalResponder; readonly onSelectUserInputOption: ( requestId: RuntimeRequestId, question: ThreadUserInputQuestion, @@ -1152,12 +1156,37 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai selectedThreadKey, ]); - const handleSendMessage = async (followUp?: ActiveTurnComposerAction) => { + const activePendingApprovalsRef = useRef(props.activePendingApprovals); + useLayoutEffect(() => { + activePendingApprovalsRef.current = props.activePendingApprovals; + }, [props.activePendingApprovals]); + const handleSendMessage = async ( + followUp?: ActiveTurnComposerAction, + stillSendable?: () => boolean, + ) => { const targetThreadKey = selectedThreadKey; const hasUserMessage = selectedThreadFeed.some( (entry) => entry.type === "message" && entry.message.role === "user", ); - const messageId = await props.onSendMessage(followUp); + // Runs only once the send path has validated the draft and is about to deliver it, so an + // empty submit, a queued-message edit, or `/feedback` leaves approvals alone. + const approvalsToDecline = props.activePendingApprovals; + const declineBeforeDispatch = + approvalsToDecline.length > 0 && props.activePendingUserInput === null + ? async () => + props.respondingApprovalId === null && + (await declinePendingApprovals(approvalsToDecline, props.onRespondToApproval)) && + selectedThreadKeyRef.current === targetThreadKey && + // A draft edited or pasted into during the decline isn't what the user sent. + stillSendable?.() !== false && + // An approval that arrived during the decline would block the redirect; the draft + // stays so the next send declines it too. + !activePendingApprovalsRef.current.some( + (approval) => + !approvalsToDecline.some((declined) => declined.requestId === approval.requestId), + ) + : undefined; + const messageId = await props.onSendMessage(followUp, declineBeforeDispatch); if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { return messageId; } @@ -1568,7 +1597,11 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai canOperateThread={props.canOperateThread} reportedModelSelection={reportedModelSelection} editorRef={composerEditorRef} - placeholder="Ask the repo agent, or run a command…" + placeholder={ + props.activePendingApproval + ? "Send a message to decline and redirect the agent" + : "Ask the repo agent, or run a command…" + } contentMaxWidth={contentMaxWidth} connectionState={props.connectionStateLabel} environmentLabel={props.environmentLabel} @@ -1592,7 +1625,12 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai : null } draftKey={props.composerDraftKey ?? undefined} - followUpBehavior={props.followUpBehavior} + followUpBehavior={ + props.activePendingApprovals.length > 0 && + props.activePendingUserInput === null + ? "steer" + : props.followUpBehavior + } canSteerActiveTurn={props.canSteerActiveTurn} hidden={composerSlotHidden} queuedEdit={ diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index e5920b06ebe5..11ac46da06d0 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/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 0abb16df6455..e134283ae541 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -932,6 +932,7 @@ function ThreadRouteContent( : null } activePendingApproval={requests.activePendingApproval} + activePendingApprovals={requests.activePendingApprovals} respondingApprovalId={requests.respondingApprovalId} activePendingUserInput={requests.activePendingUserInput} activePendingUserInputDrafts={requests.activePendingUserInputDrafts} diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts index 14751cedd402..60e37f9475d1 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 4128c874f942..14fa9e028413 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/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index ac58170cd0d4..98bec9753325 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -371,6 +371,7 @@ export function useSelectedThreadRequests() { return { activePendingApproval, + activePendingApprovals, activePendingUserInput, activePendingUserInputDrafts, activePendingUserInputAnswers, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index f40da862253f..ebdf9769d46f 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -528,7 +528,10 @@ export function useThreadComposerState() { scheduleUnusedComposerAttachmentCleanup(draft.attachments); }; - const onSendMessage = async (followUpOverride?: ActiveTurnComposerAction) => { + const onSendMessage = async ( + followUpOverride?: ActiveTurnComposerAction, + beforeDispatch?: () => Promise, + ) => { if ( selectedThreadShell && selectedEnvironmentRuntime?.connectionState === "connected" && @@ -645,15 +648,35 @@ export function useThreadComposerState() { return null; } + if (beforeDispatch !== undefined) { + if (!(await beforeDispatch())) return null; + // A context paste still landing, or any draft or settings change while approvals were + // declined, means this snapshot is no longer what the user sent. + if (appAtomRegistry.get(composerContextImportsAtom)[threadKey]) return null; + const current = getComposerDraftSnapshot(threadKey); + if ( + current.text !== draft.text || + JSON.stringify(current.context) !== JSON.stringify(draft.context) || + JSON.stringify(current.modelSelection) !== JSON.stringify(draft.modelSelection) || + current.runtimeMode !== draft.runtimeMode || + current.interactionMode !== draft.interactionMode || + current.attachments.length !== attachments.length || + current.attachments.some((attachment, index) => attachment.id !== attachments[index]?.id) + ) + return null; + } + // Resolved here rather than at drain time: the outbox can deliver minutes // later, and the choice belongs to the moment the user pressed send. // Steering travels as "auto" so a turn that ends in the meantime degrades // to a queued run on the server instead of failing the delivery and // bouncing the message back into the draft. + // A redirect declined approvals to steer the waiting turn, not to queue behind it. + const activeTurnDefault = beforeDispatch !== undefined ? "steer" : followUpBehavior; const followUpAction = resolveComposerDispatchMode({ running: activeThreadBusy && canSteerActiveTurn, - alternateModifier: followUpOverride !== undefined && followUpOverride !== followUpBehavior, - activeTurnDefault: followUpBehavior, + alternateModifier: followUpOverride !== undefined && followUpOverride !== activeTurnDefault, + activeTurnDefault, }); const setupRunning = [selectedThreadActivityRun?.status, selectedThreadRuntime?.status].some( (status) => status === "preparing" || status === "starting", diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 8fc51ff74abb..3f66714c6881 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -20,7 +20,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 +33,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 f855b0eb9ef4..517082eebaff 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/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 6dcfde2ee332..7a749b501bd3 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -46,9 +46,11 @@ const emitXAiAskUserQuestionThenHang = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1"; const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; +const grokKillTaskShape = process.env.T3_ACP_GROK_KILL_TASK_SHAPE; const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; const emitGrokMonitorPostTurnPoll = process.env.T3_ACP_EMIT_GROK_MONITOR_POST_TURN_POLL === "1"; const emitGrokBackgroundTaskStarted = process.env.T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED === "1"; +const emitGrokBackgroundTaskPoll = process.env.T3_ACP_GROK_BACKGROUND_TASK_COMPLETION === "poll"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1"; const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; @@ -461,6 +463,71 @@ function modelState(): AcpCompat.SessionModelState { }; } +function emitGrokKillTaskScenario( + sessionId: string, + promptId: string | undefined, + shape: string, +): void { + const taskId = "call-fb9d0000-0000-0000-0000-000000000026-29"; + const outcome = + shape === "already_exited" || shape === "known_failure" ? "already_exited" : "killed"; + writeJsonRpcNotification("session/update", { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "kill-target-command", + title: "run_terminal_command", + kind: "execute", + status: "in_progress", + rawInput: { command: "sleep 40" }, + }, + }); + writeJsonRpcNotification("session/update", { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "kill-target-command", + status: "completed", + rawOutput: { + type: "BackgroundTaskStarted", + task_id: taskId, + task_type: "bash", + command: "sleep 40", + }, + }, + }); + if (shape === "known_failure") { + writeJsonRpcNotification("_x.ai/task_completed", { + sessionId, + update: { + sessionUpdate: "task_completed", + task_snapshot: { task_id: taskId, kind: "bash", exit_code: 7, output: "known failure\n" }, + }, + }); + } + writeJsonRpcNotification("session/update", { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "kill-command", + title: `kill ${taskId} (${outcome})`, + kind: "other", + status: "completed", + rawInput: { + task_id: taskId, + ...(shape === "output_only" ? {} : { variant: "KillTask" }), + }, + ...(shape === "variant_only" ? {} : { rawOutput: { type: "KillTask", outcome } }), + }, + }); + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId, + promptId, + stopReason: "end_turn", + agentResult: null, + }); +} + const program = Effect.gen(function* () { const agent = yield* EffectAcpAgent.AcpAgent; const resumeRelease = yield* Deferred.make(); @@ -1498,6 +1565,16 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (grokKillTaskShape !== undefined) { + emitGrokKillTaskScenario( + requestedSessionId, + promptIdFromRequestMeta(request), + grokKillTaskShape, + ); + // Keep the prompt RPC and peer alive after the kill result. + return yield* Effect.never; + } + if (emitPlanThenHang) { yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, @@ -1685,13 +1762,15 @@ const program = Effect.gen(function* () { return yield* Effect.never; } - if (emitGrokBackgroundTaskStarted) { - const toolCallId = "call-fb9d0000-0000-0000-0000-000000000026"; + if (emitGrokBackgroundTaskStarted || emitGrokBackgroundTaskPoll) { + const toolCallId = !emitGrokBackgroundTaskPoll + ? "call-fb9d0000-0000-0000-0000-000000000026" + : "call-fb9d0000-0000-0000-0000-000000000026-29"; const command = "sleep 40; echo done-a"; writeJsonRpcNotification("session/update", { sessionId: requestedSessionId, update: { - sessionUpdate: "tool_call", + sessionUpdate: !emitGrokBackgroundTaskPoll ? "tool_call" : "tool_call_update", toolCallId, title: "run_terminal_command", kind: "execute", @@ -1714,6 +1793,46 @@ const program = Effect.gen(function* () { }, }, }); + if (emitGrokBackgroundTaskPoll) { + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "call-background-poll", + title: "get_command_or_subagent_output", + kind: "other", + status: "completed", + rawOutput: { + type: "TaskOutput", + Result: { + task_id: toolCallId, + command, + status: "completed", + exit_code: 0, + output: "done-a", + }, + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "The background command finished." }, + }, + }); + writeJsonRpcNotification("_x.ai/session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "turn_completed", + prompt_id: promptIdFromRequestMeta(request), + stop_reason: "end_turn", + }, + }); + // Neither the prompt RPC nor the process ends, matching the Grok incident. + return yield* Effect.never; + } writeJsonRpcNotification("_x.ai/session/prompt_complete", { sessionId: requestedSessionId, promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 27c52f3fb4e9..35d193b82db4 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 71cd0ced16d8..2a559927176b 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/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 569ae75907d1..1a6d3206833a 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -558,7 +558,11 @@ export class EnvironmentAuth extends Context.Service< readonly access: AuthMcpClientAccess; readonly client: AuthClientMetadata; }) => Effect.Effect< - { readonly token: string; readonly expiresAt: DateTime.DateTime }, + { + readonly sessionId: AuthSessionId; + readonly token: string; + readonly expiresAt: DateTime.DateTime; + }, ServerAuthInternalError >; /** @@ -1292,7 +1296,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 b0a2ceffef8f..2f7c24536caa 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/thread.test.ts b/apps/server/src/cli/thread.test.ts new file mode 100644 index 000000000000..c2e4bc5c8168 --- /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 000000000000..989028afccb9 --- /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/http.ts b/apps/server/src/http.ts index 6ba9579f04bd..8871a5df3451 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -581,6 +581,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 7338190ca56a..2db36ce5abc2 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/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index aecc99f7dda2..4c22393bf476 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, { environmentId: EnvironmentId.make("environment"), @@ -79,7 +96,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(), ), @@ -99,35 +116,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 912d6ad127fc..380a1b52ec96 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -9,6 +9,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"; @@ -122,10 +125,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({ @@ -133,13 +152,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" }, ...(input.message === undefined && attachments.length === 0 diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index fa4b0883c078..1a0c6caf1f92 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -63,6 +63,8 @@ import { } from "@t3tools/provider-acp/server/sessionConfig"; import { extractXAiAcpSubagentUpdate, + makeGrokAdapterV2, + makeXAiPromptCompletionRuntime, normalizeXAiAcpToolCallState, registerXAiBackgroundTaskTracking, } from "@t3tools/provider-grok/testing"; @@ -3079,6 +3081,193 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + // Oracle: Grok incident frames. Command completion must release the turn even + // when the CLI keeps the prompt RPC and process alive. Command polls stay visible. + it.live( + "completes a structured Grok background command from poll with an unanswered prompt RPC", + () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("grok-background-completion"); + const adapter = yield* makeGrokAdapterV2({ + instanceId, + settings: DEFAULT_GROK_SETTINGS, + environment: {}, + hostPlatform: yield* HostProcess.Platform, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_GROK_BACKGROUND_TASK_COMPLETION: "poll" }, + })(input).pipe(Effect.flatMap(makeXAiPromptCompletionRuntime)), + }); + const threadId = ThreadId.make("grok-background-completion"); + const modelSelection = { instanceId, model: "default" } as const; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("grok-background-completion"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const observed = yield* Stream.fromQueue(events).pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const allEvents = [...observed]; + let next = yield* Queue.poll(events); + while (Option.isSome(next)) { + allEvents.push(next.value); + next = yield* Queue.poll(events); + } + const terminals = allEvents.filter((event) => event.type === "turn.terminal"); + assert.deepEqual( + terminals.map((event) => event.status), + ["completed"], + ); + const items = allEvents.flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + assert.isFalse(items.some((item) => item.type === "subagent")); + const command = items.findLast( + (item) => item.nativeItemRef?.nativeId === "call-fb9d0000-0000-0000-0000-000000000026-29", + ); + assert.equal(command?.type, "command_execution"); + assert.equal(command?.status, "completed"); + assert.isTrue( + items.some((item) => item.nativeItemRef?.nativeId === "call-background-poll"), + "command TaskOutput must retain its normal tool frame", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + // Oracle: #15888 KillTask reports. Command completion must release the turn even + // when the CLI keeps the prompt RPC and process alive. Kill calls stay visible. + it.live.each([ + { shape: "output_only", expectedCommand: { type: "command_execution", status: "completed" } }, + { shape: "variant_only", expectedCommand: { type: "command_execution", status: "completed" } }, + { shape: "killed", expectedCommand: { type: "command_execution", status: "completed" } }, + { + shape: "already_exited", + expectedCommand: { type: "command_execution", status: "completed" }, + }, + { + shape: "known_failure", + expectedCommand: { type: "command_execution", status: "failed", output: "known failure\n" }, + }, + ] as const)( + "completes a Grok KillTask command ($shape) and preserves any known result", + ({ shape, expectedCommand }) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("grok-kill-completion"); + const adapter = yield* makeGrokAdapterV2({ + instanceId, + settings: DEFAULT_GROK_SETTINGS, + environment: {}, + hostPlatform: yield* HostProcess.Platform, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_GROK_KILL_TASK_SHAPE: shape }, + })(input).pipe(Effect.flatMap(makeXAiPromptCompletionRuntime)), + }); + const threadId = ThreadId.make("grok-kill-completion"); + const modelSelection = { instanceId, model: "default" } as const; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("grok-kill-completion"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const observed = yield* Stream.fromQueue(events).pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const allEvents = [...observed]; + let next = yield* Queue.poll(events); + while (Option.isSome(next)) { + allEvents.push(next.value); + next = yield* Queue.poll(events); + } + const terminals = allEvents.filter((event) => event.type === "turn.terminal"); + assert.deepEqual( + terminals.map((event) => event.status), + ["completed"], + ); + const items = allEvents.flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + assert.isFalse(items.some((item) => item.type === "subagent")); + const command = items.findLast( + (item) => item.nativeItemRef?.nativeId === "kill-target-command", + ); + assert.deepInclude(command, expectedCommand); + assert.isTrue( + items.some((item) => item.nativeItemRef?.nativeId === "kill-command"), + "KillTask must retain its normal tool frame", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("keeps an agent's empty-valued default choice selectable", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index c8e9ea56ff37..cc7ee268827f 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -2404,4 +2404,224 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { ); }), ); + + it.effect.each(["a background command", "a nested subagent"] as const)( + "finalizes a child whose run ended before %s settled", + (blocker) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const key = blocker === "a background command" ? "command" : "nested"; + const threadId = ThreadId.make(`late-settle-parent-${key}`); + const runId = RunId.make(`late-settle-parent-run-${key}`); + const rootNodeId = NodeId.make(`late-settle-parent-root-${key}`); + yield* seedParentWithTerminalTask({ + threadId, + projectId: ProjectId.make(`late-settle-project-${key}`), + runId, + rootNodeId, + taskId: NodeId.make(`late-settle-seed-task-${key}`), + deliveryState: "acknowledged", + now, + }); + // A delegated child with a running task on the parent and a running run of its own. + const delegate = (name: string) => + Effect.gen(function* () { + const taskId = NodeId.make(`node:late-settle-${key}-${name}`); + const childThreadId = ThreadId.make(`thread:late-settle-${key}-${name}`); + const run = runEvent({ + threadId: childThreadId, + runId: RunId.make(`run:late-settle-${key}-${name}`), + ordinal: 1, + status: "running", + now, + }); + yield* sink.write({ + events: [ + { + id: EventId.make(`event:late-settle-${key}-${name}:thread`), + type: "thread.created", + threadId: childThreadId, + occurredAt: now, + payload: { + createdBy: "agent", + creationSource: "server", + id: childThreadId, + projectId: ProjectId.make(`late-settle-project-${key}`), + title: name, + providerInstanceId: modelSelection.instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: threadId, + relationshipToParent: "subagent", + rootThreadId: threadId, + }, + forkedFrom: { type: "node", nodeId: taskId }, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + { + id: EventId.make(`event:late-settle-${key}-${name}:task`), + type: "subagent.updated", + threadId, + runId, + nodeId: taskId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: taskId, + threadId, + runId, + parentNodeId: rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: `Run ${name}.`, + title: null, + model: null, + completionWake: "always", + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }, + run, + ], + }); + return { task: { id: taskId }, childThreadId, run: run.payload }; + }); + const settled = (taskId: NodeId, afterSequence: number) => + sink.stream({ afterSequence, eventType: "subagent.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === taskId && + stored.event.payload.status === "completed", + ), + Stream.take(1), + Stream.runDrain, + ); + const blocked = yield* delegate("blocked"); + const barrier = yield* delegate("barrier"); + const providerThread = { + id: ProviderThreadId.make(`provider-thread:late-settle-child-${key}`), + driver, + providerInstanceId: modelSelection.instanceId, + providerSessionId: null, + appThreadId: blocked.childThreadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const nested = { + id: NodeId.make(`late-settle-nested-${key}`), + threadId: blocked.childThreadId, + runId: blocked.run.id, + parentNodeId: NodeId.make(`late-settle-child-root-${key}`), + origin: "provider_native" as const, + createdBy: "agent" as const, + driver, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Nested work", + title: null, + model: null, + status: "running" as const, + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }; + const blockerEvent = (open: boolean) => + blocker === "a background command" + ? { + id: EventId.make(`event:late-settle-roster-${key}-${open}`), + type: "provider-thread.updated" as const, + threadId: blocked.childThreadId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...providerThread, + pendingBackgroundTasks: open + ? [{ taskId: "sleep-900", kind: "command" as const }] + : [], + }, + } + : { + id: EventId.make(`event:late-settle-nested-${key}-${open}`), + type: "subagent.updated" as const, + threadId: blocked.childThreadId, + runId: blocked.run.id, + nodeId: nested.id, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: open + ? nested + : { ...nested, status: "completed" as const, result: "done", completedAt: now }, + }; + const ended = (child: typeof blocked) => ({ + ...runEvent({ + threadId: child.childThreadId, + runId: child.run.id, + ordinal: child.run.ordinal, + status: "completed", + now, + }), + payload: { ...child.run, status: "completed" as const, startedAt: now, completedAt: now }, + }); + + const beforeEnd = yield* sink.latestSequence(); + yield* sink.write({ events: [blockerEvent(true)] }); + yield* sink.write({ events: [ended(blocked)] }); + yield* sink.write({ events: [ended(barrier)] }); + // Run updates are handled in order, so the barrier settling means the blocked child's + // run end was handled too, while its work still ran. + yield* settled(barrier.task.id, beforeEnd); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).subagents.find( + (task) => task.id === blocked.task.id, + )?.status, + "running", + ); + + const beforeSettle = yield* sink.latestSequence(); + yield* sink.write({ events: [blockerEvent(false)] }); + yield* settled(blocked.task.id, beforeSettle); + const finished = (yield* orchestrator.getThreadProjection(threadId)).subagents.find( + (task) => task.id === blocked.task.id, + ); + assert.equal(finished?.status, "completed"); + assert.notEqual(finished?.completionDelivery?.state, "disposed"); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index f969b148fc48..fef6af496762 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -492,6 +492,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": @@ -1301,13 +1302,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); @@ -1408,7 +1413,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); @@ -1421,7 +1430,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; } @@ -2111,8 +2125,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* () { @@ -11436,6 +11452,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, @@ -11449,7 +11466,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, @@ -11593,8 +11633,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, @@ -11733,8 +11778,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Duration.millis(Math.max(0, (yield* Clock.currentTimeMillis) - dispatchStartedAtMs)), ); } - 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)); @@ -11854,6 +11904,48 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.forkDetach, ); + // A child whose run ends while its background tasks or its own subagents still run has no + // result yet. When that work settles there is no run update to finalize it, so these events + // do it (#17512). A background roster counts once it empties after it was seen non-empty. + const providerThreadsWithBackgroundWork = new Set(); + const settlesChildWork = (stored: OrchestrationV2StoredEvent) => { + const { event } = stored; + if (event.type === "subagent.updated") + return !isOrchestrationV2WorkActive(event.payload.status); + if (event.type !== "provider-thread.updated") return false; + if ((event.payload.pendingBackgroundTasks?.length ?? 0) > 0) { + providerThreadsWithBackgroundWork.add(event.payload.id); + return false; + } + return providerThreadsWithBackgroundWork.delete(event.payload.id); + }; + yield* Stream.merge( + eventSink.stream({ + afterSequence: terminalEventsAfterSequence, + eventType: "provider-thread.updated", + }), + eventSink.stream({ afterSequence: terminalEventsAfterSequence, eventType: "subagent.updated" }), + ).pipe( + Stream.filter(settlesChildWork), + Stream.runForEach((stored) => + Effect.gen(function* () { + const childThreadId = stored.event.threadId; + const parentThreadId = yield* appOwnedSubagentParentThreadId(childThreadId); + if (parentThreadId === undefined) return; + yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(childThreadId)); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to finalize a delegated child after its work settled", { + threadId: stored.event.threadId, + sequence: stored.sequence, + cause, + }), + ), + ), + ), + Effect.forkDetach, + ); + const recoverDelegatedTasks = Effect.gen(function* () { yield* projectionStore.getRecoveryThreadIds("subagent-results").pipe( Effect.tap((threadIds) => diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 441cf0a6e902..208934624d9e 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/AcpRegistryReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/AcpRegistryReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..e0a9a233bfd9 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/AcpRegistryReplayFixtures.integration.test.ts @@ -0,0 +1,213 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ProviderReplayTranscript, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import { AcpRegistryOrchestratorReplayHarness } from "../Adapters/AcpRegistryAdapterV2.testkit.ts"; +import * as EffectWorker from "../EffectWorker.ts"; +import * as Orchestrator from "../Orchestrator.ts"; +import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; +import { materializeFixtureInput, SIMPLE_PROMPT } from "./fixtures/shared.ts"; +import { + itRunsReplayFixtures, + readReplayFixtureTranscript, + runReplayFixture, +} from "./ProviderReplayFixtures.ts"; +import { layerProviderReplay } from "./ProviderReplayHarness.ts"; +import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts"; + +describe("ACP registry replay fixtures", () => { + it.effect.each([ + { name: "open", firstTurn: false, code: -32602 }, + { name: "switch", firstTurn: true, code: -32602 }, + { name: "authentication", firstTurn: false, code: -32000 }, + ])("preserves the ACP model-write $name failure through orchestration", (testCase) => + Effect.gen(function* () { + const name = `acp_model_write_${testCase.name}`; + const sessionId = "grok-replay-session-1"; + const rejectedModel = "replay-model-b"; + const modelSelection = { + instanceId: ProviderInstanceId.make("acpRegistry"), + model: rejectedModel, + }; + const recorded = yield* readReplayFixtureTranscript( + new URL("./fixtures/simple/registry_transcript.ndjson", import.meta.url), + ); + const sessionIndex = recorded.entries.findIndex( + (entry) => "label" in entry && entry.label === "session.created", + ); + assert.isAtLeast(sessionIndex, 0); + const transcript = { + ...recorded, + scenario: name, + entries: [ + ...recorded.entries.slice(0, sessionIndex), + { + type: "emit_inbound", + label: "session.created", + frame: { + kind: "response", + method: "session/new", + result: { + sessionId, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "replay-model-a", + options: [ + { value: "replay-model-a", name: "Model A" }, + { value: rejectedModel, name: "Model B" }, + ], + }, + ], + }, + }, + }, + ...(testCase.firstTurn + ? recorded.entries + .slice(sessionIndex + 1) + .filter((entry) => entry.type !== "runtime_exit") + : []), + { + type: "expect_outbound", + label: "session.set_model", + frame: { + kind: "request", + method: "session/set_config_option", + params: { sessionId, configId: "model", value: rejectedModel }, + }, + }, + { + type: "emit_inbound", + label: "session.set_model.rejected", + frame: { + kind: "response", + method: "session/set_config_option", + error: { + code: testCase.code, + message: testCase.code === -32602 ? "Invalid provider" : "Authentication required", + }, + }, + }, + ], + } satisfies ProviderReplayTranscript; + const materialized = yield* materializeFixtureInput({ + scenario: name, + fixtureInput: { + steps: Array.from({ length: testCase.firstTurn ? 2 : 1 }, () => ({ + type: "message" as const, + text: SIMPLE_PROMPT, + })), + }, + driver: ProviderDriverKind.make("acpRegistry"), + modelSelection: { + ...modelSelection, + model: testCase.firstTurn ? "replay-model-a" : rejectedModel, + }, + }); + const scenario = { + ...materialized, + name, + transcript: yield* AcpRegistryOrchestratorReplayHarness.decodeTranscript( + materializeReplayTranscriptRuntimeInstructions(transcript, { + driver: ProviderDriverKind.make("acpRegistry"), + model: "replay-model-a", + }), + ), + runtimePolicyOverride: { cwd: yield* checkpointWorkspace(name) }, + }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + if (testCase.firstTurn) yield* EffectWorker.runDaemon.pipe(Effect.forkScoped); + for (const [index, command] of materialized.commands.entries()) { + const isFirstTurn = + testCase.firstTurn && command.type === "message.dispatch" && index === 1; + yield* orchestrator.dispatch( + command.type === "message.dispatch" && !isFirstTurn + ? { ...command, modelSelection } + : command, + ); + if (isFirstTurn) { + // Wait for the committed terminal event before switching models. + yield* orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => event.type === "run.updated" && event.payload.status === "completed", + ), + Stream.runHead, + ); + } + } + if (testCase.firstTurn) { + yield* orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => event.type === "run.updated" && event.payload.status === "failed", + ), + Stream.runHead, + ); + } else { + // Failed opens retry on the outbox schedule. Advance to each recorded + // deadline, draining effects instead of polling the projection. + for (let attempt = 0; attempt < 10; attempt++) { + yield* worker.drain(); + const next = yield* worker.nextClaimableAt; + if (Option.isNone(next)) break; + const now = yield* DateTime.now; + yield* TestClock.adjust( + Math.max(0, DateTime.toEpochMillis(next.value) - DateTime.toEpochMillis(now)), + ); + } + } + const projection = yield* orchestrator.getThreadProjection( + materialized.projectionThreadIds[0]!, + ); + assert.deepEqual( + projection.runs.map((run) => run.status), + testCase.firstTurn ? ["completed", "failed"] : ["failed"], + ); + const errors = projection.turnItems.filter((item) => item.type === "error"); + assert.lengthOf(errors, 1); + assert.equal(errors[0]?.failure.class, "provider_error"); + if (testCase.code === -32602) { + assert.equal( + errors[0]?.failure.message, + 'The agent did not accept the model "replay-model-b". Choose another model for this provider, then retry.', + ); + } else { + assert.match( + errors[0]?.failure.message ?? "", + /^Failed to open acpRegistry provider session \S+\.$/u, + ); + } + }).pipe( + Effect.provide( + layerProviderReplay(scenario, AcpRegistryOrchestratorReplayHarness, { + runEffectWorker: false, + }), + ), + ); + }).pipe( + Effect.scoped, + provideDeterministicTestRuntime, + Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), + ), + ); + + itRunsReplayFixtures("acpRegistry", (input) => + runReplayFixture({ ...input, harness: AcpRegistryOrchestratorReplayHarness }), + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts index 94a33411b8b9..8886daad3e01 100644 --- a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts @@ -5,6 +5,7 @@ import { isProviderNativeSubagentThread, MessageId, ProviderDriverKind, + type ProviderReplayEntry, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -17,7 +18,17 @@ import { subagentInput } from "./fixtures/subagent/input.ts"; import { runOrchestratorV2Scenario } from "./OrchestratorScenario.ts"; import * as ProviderReplayHarness from "./ProviderReplayHarness.ts"; import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts"; -import { CLAUDE_MODEL_SELECTION, materializeFixtureInput } from "./fixtures/shared.ts"; +import { + CLAUDE_MODEL_SELECTION, + materializeFixtureInput, + projectionFor, +} from "./fixtures/shared.ts"; +import { + findReplayFixture, + itRunsReplayFixtures, + runReplayFixture, + type ReplayFixtureCase, +} from "./ProviderReplayFixtures.ts"; import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; const readTranscript = Effect.fn("readClaudeReplayFixture")(function* (file: URL) { @@ -28,7 +39,13 @@ function readClaudeTranscriptFixture(path: string) { return readTranscript(new URL(`./fixtures/${path}/claude_transcript.ndjson`, import.meta.url)); } +function runClaudeReplayFixture(input: ReplayFixtureCase) { + return runReplayFixture({ ...input, harness: ClaudeOrchestratorReplayHarness }); +} + describe("Claude Agent SDK replay fixtures", () => { + itRunsReplayFixtures("claudeAgent", runClaudeReplayFixture); + it.effect("refuses messages to a native subagent thread without touching it", () => Effect.gen(function* () { const raw = yield* readClaudeTranscriptFixture("subagent"); @@ -91,4 +108,212 @@ describe("Claude Agent SDK replay fixtures", () => { ); }), ); + + const afterRootFixture = findReplayFixture("claude_background_subagent_after_root"); + const afterRootProvider = afterRootFixture?.providers[0]; + if (afterRootFixture !== undefined && afterRootProvider !== undefined) { + const OPEN_TOOL_ID = "toolu_01QDa5jV5g1H9h6QyDfeJohD"; + const SUBAGENT_TASK_ID = "a2995bfced8019363"; + + const moveOpenToolBeforeRootResult = ( + move: boolean, + entries: ReadonlyArray, + ): ReadonlyArray => { + if (!move) return entries; + const toolIndex = entries.findIndex( + (entry) => + entry.type === "emit_inbound" && + (entry.frame as Record).message?.content?.[0]?.id === OPEN_TOOL_ID, + ); + const resultIndex = entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "result", + ); + if (toolIndex < 0 || resultIndex < 0) throw new Error("transcript shape changed"); + const toolEntry = entries[toolIndex]!; + const rest = entries.filter((_, index) => index !== toolIndex); + return [...rest.slice(0, resultIndex), toolEntry, ...rest.slice(resultIndex)]; + }; + // A subagent's foreground Bash run during the root's turn is tailed like a + // root call's, so the child's command row streams live. + const SUBAGENT_BASH_ID = "toolu_01JMN3SSQCHzUTNua9AWdkiH"; + it.effect("streams a subagent's foreground Bash output live during the root turn", () => + runClaudeReplayFixture({ + fixtureName: afterRootFixture.name, + buildInput: afterRootFixture.buildInput, + driver: { + ...afterRootProvider, + assertOutput: (result) => { + const childThreadId = projectionFor(result, afterRootFixture.name).subagents[0] + ?.childThreadId; + assert.exists(childThreadId); + const command = result.projections + .get(childThreadId) + ?.turnItems.find( + (item) => + item.type === "command_execution" && + JSON.stringify(item.input).includes("SUB_DONE_1"), + ); + assert.exists(command); + const streamed = result.commandOutput?.get(command.id); + assert.exists(streamed, "the subagent's command must stream its output"); + assert.include(streamed.output.text, "SUB_DONE_1"); + }, + }, + transformTranscript: (transcript) => { + const isBashFrame = (entry: ProviderReplayEntry) => { + if (entry.type !== "emit_inbound") return false; + const frame = entry.frame as Record; + return ( + frame.message?.content?.[0]?.id === SUBAGENT_BASH_ID || + frame.message?.content?.[0]?.tool_use_id === SUBAGENT_BASH_ID || + (frame.type === "system" && frame.tool_use_id === SUBAGENT_BASH_ID) + ); + }; + // Recorded without the structured result the replay writes output files from. + const bashFrames = transcript.entries.filter(isBashFrame).map((entry) => { + const frame = (entry as { readonly frame: Record }).frame; + return frame.type === "user" + ? { + ...entry, + frame: { ...frame, tool_use_result: { stdout: "SUB_DONE_1\n", stderr: "" } }, + } + : entry; + }); + const rest = transcript.entries.filter((entry) => !isBashFrame(entry)); + const resultIndex = rest.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "result", + ); + assert.lengthOf(bashFrames, 4, "transcript shape changed"); + assert.isAtLeast(resultIndex, 0, "transcript shape changed"); + return { + ...transcript, + entries: [...rest.slice(0, resultIndex), ...bashFrames, ...rest.slice(resultIndex)], + }; + }, + }), + ); + + // regression: #16486 a background subagent's tool left open while its parent is idle never settled + it.effect.each([ + ["WebFetch", "while root is idle", "stopped"], + ["Write", "before root settles", "failed"], + ] as const)( + "ends a background subagent's open %s (opened %s) when its notification is %s", + ([tool, opens, notificationStatus]) => + runClaudeReplayFixture({ + fixtureName: afterRootFixture.name, + buildInput: afterRootFixture.buildInput, + driver: { + ...afterRootProvider, + assertOutput: (result) => { + const parent = projectionFor(result, afterRootFixture.name); + assert.deepEqual( + parent.runs.map((run) => run.status), + ["completed", "completed"], + ); + const subagent = parent.subagents[0]; + assert.equal( + subagent?.status, + notificationStatus === "stopped" ? "cancelled" : "failed", + ); + const childThreadId = subagent?.childThreadId; + assert.exists(childThreadId); + const child = result.projections.get(childThreadId); + assert.exists(child); + const fetches = child.turnItems.filter( + (item) => item.type === (tool === "WebFetch" ? "web_search" : "file_change"), + ); + assert.lengthOf(fetches, 1, `the ${tool} reached the child thread`); + assert.deepEqual( + { + openTool: fetches.map((item) => item.status), + openChildNodes: child.nodes + .filter( + (node) => + node.status === "running" || + node.status === "pending" || + node.status === "waiting", + ) + .map((node) => `${node.kind}:${node.status}`), + }, + { + openTool: [notificationStatus === "stopped" ? "interrupted" : "failed"], + openChildNodes: [], + }, + ); + }, + }, + transformTranscript: (transcript) => ({ + ...transcript, + entries: moveOpenToolBeforeRootResult( + opens === "before root settles", + transcript.entries.flatMap((entry): ReadonlyArray => { + if (entry.type !== "emit_inbound") return [entry]; + const frame = entry.frame as Record; + if ( + frame.type === "assistant" && + frame.message?.content?.[0]?.id === OPEN_TOOL_ID + ) { + return [ + { + ...entry, + frame: { + ...frame, + message: { + ...frame.message, + content: [ + { + ...frame.message.content[0], + name: tool, + input: + tool === "WebFetch" + ? { url: "https://example.com", prompt: "Summarize" } + : { file_path: "notes.txt", content: "SUB_DONE_2" }, + }, + ], + }, + }, + }, + ]; + } + if ( + (frame.type === "system" && + (frame.subtype === "task_started" || frame.subtype === "task_notification") && + frame.tool_use_id === OPEN_TOOL_ID) || + (frame.type === "user" && + frame.message?.content?.[0]?.tool_use_id === OPEN_TOOL_ID) || + (frame.type === "assistant" && + frame.parent_tool_use_id !== null && + frame.message?.content?.[0]?.text === "SUB_FINAL_REPORT") + ) { + return []; + } + if (frame.type === "system" && frame.subtype === "task_updated") { + return [ + { + ...entry, + frame: { + ...frame, + patch: { + ...frame.patch, + status: notificationStatus === "stopped" ? "killed" : "failed", + }, + }, + }, + ]; + } + if ( + frame.type === "system" && + frame.subtype === "task_notification" && + frame.task_id === SUBAGENT_TASK_ID + ) { + return [{ ...entry, frame: { ...frame, status: notificationStatus } }]; + } + return [entry]; + }), + ), + }), + }), + ); + } }); diff --git a/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..c0593351f20d --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts @@ -0,0 +1,17 @@ +import { describe } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { materializeReplayTranscriptWorkspace } from "@t3tools/provider-testing/replayTranscript"; +import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; +import { itRunsReplayFixtures, runReplayFixture } from "./ProviderReplayFixtures.ts"; + +describe("Codex replay fixtures", () => { + itRunsReplayFixtures("codex", (input) => + runReplayFixture({ + ...input, + harness: CodexOrchestratorReplayHarness, + materializeWorkspace: (transcript, workspace) => + Effect.succeed(materializeReplayTranscriptWorkspace(transcript, workspace)), + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..7557e8b3929f --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from "@effect/vitest"; + +import { CursorOrchestratorReplayHarness } from "../Adapters/CursorAdapterV2.testkit.ts"; +import { messageRestartInput } from "./fixtures/message_steering/input.ts"; +import { + findReplayFixture, + itRunsReplayFixtures, + runReplayFixture, +} from "./ProviderReplayFixtures.ts"; + +describe("Cursor replay fixtures", () => { + itRunsReplayFixtures("cursor", (input) => + runReplayFixture({ ...input, harness: CursorOrchestratorReplayHarness }), + ); + + const cursorSteeringProvider = findReplayFixture("message_steering")?.providers.find( + (provider) => provider.driver === "cursor", + ); + if (cursorSteeringProvider !== undefined) { + it.effect("executes explicit Cursor restart_active through the recorded SDK boundary", () => + runReplayFixture({ + fixtureName: "message_steering", + buildInput: messageRestartInput, + driver: cursorSteeringProvider, + harness: CursorOrchestratorReplayHarness, + }), + ); + } +}); diff --git a/apps/server/src/orchestration-v2/testkit/GrokReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/GrokReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..606539ea7f46 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/GrokReplayFixtures.integration.test.ts @@ -0,0 +1,10 @@ +import { describe } from "@effect/vitest"; + +import { GrokOrchestratorReplayHarness } from "../Adapters/GrokAdapterV2.testkit.ts"; +import { itRunsReplayFixtures, runReplayFixture } from "./ProviderReplayFixtures.ts"; + +describe("Grok replay fixtures", () => { + itRunsReplayFixtures("grok", (input) => + runReplayFixture({ ...input, harness: GrokOrchestratorReplayHarness }), + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/MuseReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/MuseReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..fee15966df4e --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/MuseReplayFixtures.integration.test.ts @@ -0,0 +1,17 @@ +import { describe } from "@effect/vitest"; + +import { + materializeMuseReplayWorkspace, + MuseOrchestratorReplayHarness, +} from "../Adapters/MuseAdapterV2.testkit.ts"; +import { itRunsReplayFixtures, runReplayFixture } from "./ProviderReplayFixtures.ts"; + +describe("Muse replay fixtures", () => { + itRunsReplayFixtures("muse", (input) => + runReplayFixture({ + ...input, + harness: MuseOrchestratorReplayHarness, + materializeWorkspace: materializeMuseReplayWorkspace, + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..f9a594042b71 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts @@ -0,0 +1,68 @@ +import { describe, it } from "@effect/vitest"; +import type { ProviderReplayEntry } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { OpenCodeOrchestratorReplayHarness } from "../Adapters/OpenCodeAdapterV2.testkit.ts"; +import { + OPENCODE2_HTTP_PROTOCOL, + OpenCode2OrchestratorReplayHarness, +} from "../Adapters/OpenCode2AdapterV2.testkit.ts"; +import { + findReplayFixture, + itRunsReplayFixtures, + readReplayFixtureTranscript, + runReplayFixture, + type ReplayFixtureCase, +} from "./ProviderReplayFixtures.ts"; + +// OpenCode fixtures are recorded against either the v1 or the v2 HTTP protocol. +function runOpenCodeReplayFixture(input: ReplayFixtureCase) { + return readReplayFixtureTranscript(input.driver.transcriptFile).pipe( + Effect.flatMap((transcript) => + transcript.protocol === OPENCODE2_HTTP_PROTOCOL + ? runReplayFixture({ ...input, harness: OpenCode2OrchestratorReplayHarness }) + : runReplayFixture({ ...input, harness: OpenCodeOrchestratorReplayHarness }), + ), + ); +} + +describe("OpenCode replay fixtures", () => { + itRunsReplayFixtures("opencode", runOpenCodeReplayFixture); + + it.effect.each( + ( + [ + ["opencode2_subagent", "session.execution.started.2"], + ["opencode2_background", "session.execution.started.3"], + ] as const + ).flatMap(([fixtureName, label]) => { + const fixture = findReplayFixture(fixtureName); + const provider = fixture?.providers[0]; + return fixture === undefined || provider === undefined + ? [] + : [[fixtureName, label, fixture, provider] as const]; + }), + )( + "runs %s when %s is an execution start this build cannot decode", + ([fixtureName, label, fixture, provider]) => + runOpenCodeReplayFixture({ + fixtureName, + buildInput: fixture.buildInput, + driver: provider, + transformTranscript: (transcript) => ({ + ...transcript, + entries: transcript.entries.map((entry) => + entry.type === "emit_inbound" && entry.label === label + ? undecodableEvent(entry) + : entry, + ), + }), + }), + ); +}); + +function undecodableEvent(entry: ProviderReplayEntry): ProviderReplayEntry { + if (entry.type !== "emit_inbound") return entry; + const frame = entry.frame as { readonly event: Record }; + return { ...entry, frame: { ...frame, event: { ...frame.event, durable: "not-an-envelope" } } }; +} diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts deleted file mode 100644 index 3a3fe85ffc4e..000000000000 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ /dev/null @@ -1,704 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import { - ProviderDriverKind, - ProviderInstanceId, - type OrchestrationV2DomainEvent, - type ProviderReplayEntry, - type ProviderReplayTranscript, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -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 Stream from "effect/Stream"; -import { TestClock } from "effect/testing"; - -import * as EffectWorker from "../EffectWorker.ts"; -import * as Orchestrator from "../Orchestrator.ts"; - -import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; -import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; -import { CursorOrchestratorReplayHarness } from "../Adapters/CursorAdapterV2.testkit.ts"; -import { AcpRegistryOrchestratorReplayHarness } from "../Adapters/AcpRegistryAdapterV2.testkit.ts"; -import { GrokOrchestratorReplayHarness } from "../Adapters/GrokAdapterV2.testkit.ts"; -import { OpenCodeOrchestratorReplayHarness } from "../Adapters/OpenCodeAdapterV2.testkit.ts"; -import { - OPENCODE2_HTTP_PROTOCOL, - OpenCode2OrchestratorReplayHarness, -} from "../Adapters/OpenCode2AdapterV2.testkit.ts"; -import { - materializeMuseReplayWorkspace, - MuseOrchestratorReplayHarness, -} from "../Adapters/MuseAdapterV2.testkit.ts"; -import { PiOrchestratorReplayHarness } from "../Adapters/PiAdapterV2.testkit.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; -import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; -import { messageRestartInput } from "./fixtures/message_steering/input.ts"; -import { - assertProviderNativeSubagentRootTurns, - materializeFixtureInput, - SIMPLE_PROMPT, - projectionFor, - type OrchestratorFixtureInput, - type ProviderOrchestratorReplayVariant, -} from "./fixtures/shared.ts"; -import { - runOrchestratorV2ProviderReplayScenario, - layerProviderReplay, - type OrchestratorV2ProviderReplayHarness, -} from "./ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; -import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts"; -import { - materializeReplayTranscriptWorkspace, - readProviderReplayTranscript, -} from "@t3tools/provider-testing/replayTranscript"; - -const readTranscript = Effect.fn("readOrchestratorReplayTranscript")(function* (file: URL) { - return yield* readProviderReplayTranscript(file); -}, Effect.provide(NodeServices.layer)); - -function normalizeTestError(cause: unknown): Error { - return cause instanceof Error ? cause : new Error(String(cause)); -} - -function transcriptEntriesThroughLabel( - transcript: ProviderReplayTranscript, - label: string | undefined, -): ProviderReplayTranscript { - if (label === undefined) { - return transcript; - } - const entryIndex = transcript.entries.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === label, - ); - if (entryIndex === -1) { - throw new Error(`${transcript.scenario} is missing inbound transcript label ${label}.`); - } - return { - ...transcript, - entries: transcript.entries.slice(0, entryIndex + 1), - }; -} - -function isStreamingAssistantEvent(event: OrchestrationV2DomainEvent): boolean { - switch (event.type) { - case "node.updated": - return event.payload.kind === "assistant_message" && event.payload.status === "running"; - case "message.updated": - return event.payload.role === "assistant" && event.payload.streaming; - case "turn-item.updated": - return event.payload.type === "assistant_message" && event.payload.streaming; - default: - return false; - } -} - -const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < - Transcript extends ProviderReplayTranscript, - Error, ->(input: { - readonly fixtureName: string; - readonly buildInput: () => OrchestratorFixtureInput; - readonly driver: ProviderOrchestratorReplayVariant; - readonly harness: OrchestratorV2ProviderReplayHarness; - readonly transformTranscript?: (transcript: ProviderReplayTranscript) => ProviderReplayTranscript; -}) { - const recordedTranscript = yield* readTranscript(input.driver.transcriptFile); - const rawTranscript = input.transformTranscript?.(recordedTranscript) ?? recordedTranscript; - const replayTranscript = materializeReplayTranscriptRuntimeInstructions( - transcriptEntriesThroughLabel(rawTranscript, input.driver.transcriptEntriesThroughLabel), - { driver: input.driver.driver, model: input.driver.modelSelection.model }, - ); - const fixtureInput = input.buildInput(); - const workspace = yield* checkpointWorkspace(input.fixtureName, fixtureInput.workspaceFiles); - const transcript = yield* input.harness.decodeTranscript( - input.driver.driver === "codex" - ? materializeReplayTranscriptWorkspace(replayTranscript, workspace) - : input.driver.driver === "muse" - ? yield* materializeMuseReplayWorkspace(replayTranscript, workspace) - : replayTranscript, - ); - const materialized = yield* materializeFixtureInput({ - scenario: input.fixtureName, - fixtureInput, - driver: input.driver.driver, - modelSelection: input.driver.modelSelection, - }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); - const scenario = { - name: `${input.fixtureName}/${input.driver.driver}`, - transcript, - commands: materialized.commands, - steps: materialized.steps, - projectionThreadIds: materialized.projectionThreadIds, - runtimePolicyOverride: { - ...input.driver.runtimePolicyOverride, - cwd: workspace, - }, - }; - - const result = yield* runOrchestratorV2ProviderReplayScenario( - scenario, - input.harness, - input.driver.runContinuationWorker === true ? { runContinuationWorker: true } : {}, - ).pipe(provideDeterministicTestRuntime); - input.driver.assertOutput(result, transcript); - assertProviderNativeSubagentRootTurns(result); - const expectedAbsentWorkspacePaths = input.driver.expectedAbsentWorkspacePaths; - if (expectedAbsentWorkspacePaths !== undefined) { - yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - for (const relativePath of expectedAbsentWorkspacePaths) { - assert.isFalse( - yield* fs.exists(path.join(workspace, relativePath)), - `${input.fixtureName}/${input.driver.driver} must not create ${relativePath} in the replay workspace`, - ); - } - }).pipe(Effect.provide(NodeServices.layer)); - } - assert.isFalse( - result.domainEvents.some(isStreamingAssistantEvent), - "buffered delivery must not persist streaming assistant artifacts", - ); - const projectionThreadId = materialized.projectionThreadIds[0]; - assert.isDefined(projectionThreadId); - const projection = result.projections.get(projectionThreadId); - assert.isDefined(projection); - const latestRun = projection.runs.at(-1); - assert.deepEqual(latestRun?.modelSelection, input.driver.modelSelection); - if (projection.runs.some((run) => run.status === "completed")) { - const threadStartCheckpoint = projection.checkpoints.find( - (checkpoint) => checkpoint.ordinalWithinScope === 0 && checkpoint.appRunOrdinal === null, - ); - assert.isDefined( - threadStartCheckpoint, - "completed threads must retain an addressable thread-start checkpoint", - ); - assert.equal(threadStartCheckpoint.status, "ready"); - } - return result; -}); - -function runFixtureProviderWithRegisteredHarness(input: { - readonly fixtureName: string; - readonly buildInput: () => OrchestratorFixtureInput; - readonly driver: ProviderOrchestratorReplayVariant; - readonly transformTranscript?: (transcript: ProviderReplayTranscript) => ProviderReplayTranscript; -}) { - switch (input.driver.driver) { - case "codex": - return runFixtureProvider({ - ...input, - harness: CodexOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "claudeAgent": - return runFixtureProvider({ - ...input, - harness: ClaudeOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "cursor": - return runFixtureProvider({ - ...input, - harness: CursorOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "grok": - return runFixtureProvider({ - ...input, - harness: GrokOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "acpRegistry": - return runFixtureProvider({ - ...input, - harness: AcpRegistryOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "opencode": - return readTranscript(input.driver.transcriptFile).pipe( - Effect.flatMap((transcript) => - transcript.protocol === OPENCODE2_HTTP_PROTOCOL - ? runFixtureProvider({ ...input, harness: OpenCode2OrchestratorReplayHarness }) - : runFixtureProvider({ ...input, harness: OpenCodeOrchestratorReplayHarness }), - ), - Effect.mapError(normalizeTestError), - Effect.scoped, - ); - case "pi": - return runFixtureProvider({ - ...input, - harness: PiOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "muse": - return runFixtureProvider({ - ...input, - harness: MuseOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - default: - return Effect.die( - new Error(`No replay harness registered for provider ${input.driver.driver}.`), - ); - } -} - -describe("orchestrator replay fixtures", () => { - it.effect.each([ - { name: "open", firstTurn: false, code: -32602 }, - { name: "switch", firstTurn: true, code: -32602 }, - { name: "authentication", firstTurn: false, code: -32000 }, - ])("preserves the ACP model-write $name failure through orchestration", (testCase) => - Effect.gen(function* () { - const name = `acp_model_write_${testCase.name}`; - const sessionId = "grok-replay-session-1"; - const rejectedModel = "replay-model-b"; - const modelSelection = { - instanceId: ProviderInstanceId.make("acpRegistry"), - model: rejectedModel, - }; - const recorded = yield* readTranscript( - new URL("./fixtures/simple/registry_transcript.ndjson", import.meta.url), - ); - const sessionIndex = recorded.entries.findIndex( - (entry) => "label" in entry && entry.label === "session.created", - ); - assert.isAtLeast(sessionIndex, 0); - const transcript = { - ...recorded, - scenario: name, - entries: [ - ...recorded.entries.slice(0, sessionIndex), - { - type: "emit_inbound", - label: "session.created", - frame: { - kind: "response", - method: "session/new", - result: { - sessionId, - configOptions: [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: "replay-model-a", - options: [ - { value: "replay-model-a", name: "Model A" }, - { value: rejectedModel, name: "Model B" }, - ], - }, - ], - }, - }, - }, - ...(testCase.firstTurn - ? recorded.entries - .slice(sessionIndex + 1) - .filter((entry) => entry.type !== "runtime_exit") - : []), - { - type: "expect_outbound", - label: "session.set_model", - frame: { - kind: "request", - method: "session/set_config_option", - params: { sessionId, configId: "model", value: rejectedModel }, - }, - }, - { - type: "emit_inbound", - label: "session.set_model.rejected", - frame: { - kind: "response", - method: "session/set_config_option", - error: { - code: testCase.code, - message: testCase.code === -32602 ? "Invalid provider" : "Authentication required", - }, - }, - }, - ], - } satisfies ProviderReplayTranscript; - const materialized = yield* materializeFixtureInput({ - scenario: name, - fixtureInput: { - steps: Array.from({ length: testCase.firstTurn ? 2 : 1 }, () => ({ - type: "message" as const, - text: SIMPLE_PROMPT, - })), - }, - driver: ProviderDriverKind.make("acpRegistry"), - modelSelection: { - ...modelSelection, - model: testCase.firstTurn ? "replay-model-a" : rejectedModel, - }, - }); - const scenario = { - ...materialized, - name, - transcript: yield* AcpRegistryOrchestratorReplayHarness.decodeTranscript( - materializeReplayTranscriptRuntimeInstructions(transcript, { - driver: ProviderDriverKind.make("acpRegistry"), - model: "replay-model-a", - }), - ), - runtimePolicyOverride: { cwd: yield* checkpointWorkspace(name) }, - }; - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - if (testCase.firstTurn) yield* EffectWorker.runDaemon.pipe(Effect.forkScoped); - for (const [index, command] of materialized.commands.entries()) { - const isFirstTurn = - testCase.firstTurn && command.type === "message.dispatch" && index === 1; - yield* orchestrator.dispatch( - command.type === "message.dispatch" && !isFirstTurn - ? { ...command, modelSelection } - : command, - ); - if (isFirstTurn) { - // Wait for the committed terminal event before switching models. - yield* orchestrator.streamStoredEvents.pipe( - Stream.filter( - ({ event }) => event.type === "run.updated" && event.payload.status === "completed", - ), - Stream.runHead, - ); - } - } - if (testCase.firstTurn) { - yield* orchestrator.streamStoredEvents.pipe( - Stream.filter( - ({ event }) => event.type === "run.updated" && event.payload.status === "failed", - ), - Stream.runHead, - ); - } else { - // Failed opens retry on the outbox schedule. Advance to each recorded - // deadline, draining effects instead of polling the projection. - for (let attempt = 0; attempt < 10; attempt++) { - yield* worker.drain(); - const next = yield* worker.nextClaimableAt; - if (Option.isNone(next)) break; - const now = yield* DateTime.now; - yield* TestClock.adjust( - Math.max(0, DateTime.toEpochMillis(next.value) - DateTime.toEpochMillis(now)), - ); - } - } - const projection = yield* orchestrator.getThreadProjection( - materialized.projectionThreadIds[0]!, - ); - assert.deepEqual( - projection.runs.map((run) => run.status), - testCase.firstTurn ? ["completed", "failed"] : ["failed"], - ); - const errors = projection.turnItems.filter((item) => item.type === "error"); - assert.lengthOf(errors, 1); - assert.equal(errors[0]?.failure.class, "provider_error"); - if (testCase.code === -32602) { - assert.equal( - errors[0]?.failure.message, - 'The agent did not accept the model "replay-model-b". Choose another model for this provider, then retry.', - ); - } else { - assert.match( - errors[0]?.failure.message ?? "", - /^Failed to open acpRegistry provider session \S+\.$/u, - ); - } - }).pipe( - Effect.provide( - layerProviderReplay(scenario, AcpRegistryOrchestratorReplayHarness, { - runEffectWorker: false, - }), - ), - ); - }).pipe( - Effect.scoped, - provideDeterministicTestRuntime, - Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), - ), - ); - - it.effect.each( - ORCHESTRATOR_REPLAY_FIXTURES.flatMap((fixture) => - fixture.providers.map( - (provider) => [fixture.name, provider.driver, fixture, provider] as const, - ), - ), - )("runs %s/%s through OrchestratorV2 using deterministic replay", ([, , fixture, provider]) => - runFixtureProviderWithRegisteredHarness({ - fixtureName: fixture.name, - buildInput: fixture.buildInput, - driver: provider, - }), - ); - - const steeringFixture = ORCHESTRATOR_REPLAY_FIXTURES.find( - (fixture) => fixture.name === "message_steering", - ); - const cursorSteeringProvider = steeringFixture?.providers.find( - (provider) => provider.driver === "cursor", - ); - if (cursorSteeringProvider !== undefined) { - it.effect("executes explicit Cursor restart_active through the recorded SDK boundary", () => - runFixtureProviderWithRegisteredHarness({ - fixtureName: "message_steering", - buildInput: messageRestartInput, - driver: cursorSteeringProvider, - }), - ); - } - - it.effect.each( - ( - [ - ["opencode2_subagent", "session.execution.started.2"], - ["opencode2_background", "session.execution.started.3"], - ] as const - ).flatMap(([fixtureName, label]) => { - const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find( - (candidate) => candidate.name === fixtureName, - ); - const provider = fixture?.providers[0]; - return fixture === undefined || provider === undefined - ? [] - : [[fixtureName, label, fixture, provider] as const]; - }), - )( - "runs %s when %s is an execution start this build cannot decode", - ([fixtureName, label, fixture, provider]) => - runFixtureProviderWithRegisteredHarness({ - fixtureName, - buildInput: fixture.buildInput, - driver: provider, - transformTranscript: (transcript) => ({ - ...transcript, - entries: transcript.entries.map((entry) => - entry.type === "emit_inbound" && entry.label === label - ? undecodableEvent(entry) - : entry, - ), - }), - }), - ); - - const afterRootFixture = ORCHESTRATOR_REPLAY_FIXTURES.find( - (candidate) => candidate.name === "claude_background_subagent_after_root", - ); - const afterRootProvider = afterRootFixture?.providers[0]; - if (afterRootFixture !== undefined && afterRootProvider !== undefined) { - const OPEN_TOOL_ID = "toolu_01QDa5jV5g1H9h6QyDfeJohD"; - const SUBAGENT_TASK_ID = "a2995bfced8019363"; - - const moveOpenToolBeforeRootResult = ( - move: boolean, - entries: ReadonlyArray, - ): ReadonlyArray => { - if (!move) return entries; - const toolIndex = entries.findIndex( - (entry) => - entry.type === "emit_inbound" && - (entry.frame as Record).message?.content?.[0]?.id === OPEN_TOOL_ID, - ); - const resultIndex = entries.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === "result", - ); - if (toolIndex < 0 || resultIndex < 0) throw new Error("transcript shape changed"); - const toolEntry = entries[toolIndex]!; - const rest = entries.filter((_, index) => index !== toolIndex); - return [...rest.slice(0, resultIndex), toolEntry, ...rest.slice(resultIndex)]; - }; - // A subagent's foreground Bash run during the root's turn is tailed like a - // root call's, so the child's command row streams live. - const SUBAGENT_BASH_ID = "toolu_01JMN3SSQCHzUTNua9AWdkiH"; - it.effect("streams a subagent's foreground Bash output live during the root turn", () => - runFixtureProviderWithRegisteredHarness({ - fixtureName: afterRootFixture.name, - buildInput: afterRootFixture.buildInput, - driver: { - ...afterRootProvider, - assertOutput: (result) => { - const childThreadId = projectionFor(result, afterRootFixture.name).subagents[0] - ?.childThreadId; - assert.exists(childThreadId); - const command = result.projections - .get(childThreadId) - ?.turnItems.find( - (item) => - item.type === "command_execution" && - JSON.stringify(item.input).includes("SUB_DONE_1"), - ); - assert.exists(command); - const streamed = result.commandOutput?.get(command.id); - assert.exists(streamed, "the subagent's command must stream its output"); - assert.include(streamed.output.text, "SUB_DONE_1"); - }, - }, - transformTranscript: (transcript) => { - const isBashFrame = (entry: ProviderReplayEntry) => { - if (entry.type !== "emit_inbound") return false; - const frame = entry.frame as Record; - return ( - frame.message?.content?.[0]?.id === SUBAGENT_BASH_ID || - frame.message?.content?.[0]?.tool_use_id === SUBAGENT_BASH_ID || - (frame.type === "system" && frame.tool_use_id === SUBAGENT_BASH_ID) - ); - }; - // Recorded without the structured result the replay writes output files from. - const bashFrames = transcript.entries.filter(isBashFrame).map((entry) => { - const frame = (entry as { readonly frame: Record }).frame; - return frame.type === "user" - ? { - ...entry, - frame: { ...frame, tool_use_result: { stdout: "SUB_DONE_1\n", stderr: "" } }, - } - : entry; - }); - const rest = transcript.entries.filter((entry) => !isBashFrame(entry)); - const resultIndex = rest.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === "result", - ); - assert.lengthOf(bashFrames, 4, "transcript shape changed"); - assert.isAtLeast(resultIndex, 0, "transcript shape changed"); - return { - ...transcript, - entries: [...rest.slice(0, resultIndex), ...bashFrames, ...rest.slice(resultIndex)], - }; - }, - }), - ); - - // regression: #16486 a background subagent's tool left open while its parent is idle never settled - it.effect.each([ - ["WebFetch", "while root is idle", "stopped"], - ["Write", "before root settles", "failed"], - ] as const)( - "ends a background subagent's open %s (opened %s) when its notification is %s", - ([tool, opens, notificationStatus]) => - runFixtureProviderWithRegisteredHarness({ - fixtureName: afterRootFixture.name, - buildInput: afterRootFixture.buildInput, - driver: { - ...afterRootProvider, - assertOutput: (result) => { - const parent = projectionFor(result, afterRootFixture.name); - assert.deepEqual( - parent.runs.map((run) => run.status), - ["completed", "completed"], - ); - const subagent = parent.subagents[0]; - assert.equal( - subagent?.status, - notificationStatus === "stopped" ? "cancelled" : "failed", - ); - const childThreadId = subagent?.childThreadId; - assert.exists(childThreadId); - const child = result.projections.get(childThreadId); - assert.exists(child); - const fetches = child.turnItems.filter( - (item) => item.type === (tool === "WebFetch" ? "web_search" : "file_change"), - ); - assert.lengthOf(fetches, 1, `the ${tool} reached the child thread`); - assert.deepEqual( - { - openTool: fetches.map((item) => item.status), - openChildNodes: child.nodes - .filter( - (node) => - node.status === "running" || - node.status === "pending" || - node.status === "waiting", - ) - .map((node) => `${node.kind}:${node.status}`), - }, - { - openTool: [notificationStatus === "stopped" ? "interrupted" : "failed"], - openChildNodes: [], - }, - ); - }, - }, - transformTranscript: (transcript) => ({ - ...transcript, - entries: moveOpenToolBeforeRootResult( - opens === "before root settles", - transcript.entries.flatMap((entry): ReadonlyArray => { - if (entry.type !== "emit_inbound") return [entry]; - const frame = entry.frame as Record; - if ( - frame.type === "assistant" && - frame.message?.content?.[0]?.id === OPEN_TOOL_ID - ) { - return [ - { - ...entry, - frame: { - ...frame, - message: { - ...frame.message, - content: [ - { - ...frame.message.content[0], - name: tool, - input: - tool === "WebFetch" - ? { url: "https://example.com", prompt: "Summarize" } - : { file_path: "notes.txt", content: "SUB_DONE_2" }, - }, - ], - }, - }, - }, - ]; - } - if ( - (frame.type === "system" && - (frame.subtype === "task_started" || frame.subtype === "task_notification") && - frame.tool_use_id === OPEN_TOOL_ID) || - (frame.type === "user" && - frame.message?.content?.[0]?.tool_use_id === OPEN_TOOL_ID) || - (frame.type === "assistant" && - frame.parent_tool_use_id !== null && - frame.message?.content?.[0]?.text === "SUB_FINAL_REPORT") - ) { - return []; - } - if (frame.type === "system" && frame.subtype === "task_updated") { - return [ - { - ...entry, - frame: { - ...frame, - patch: { - ...frame.patch, - status: notificationStatus === "stopped" ? "killed" : "failed", - }, - }, - }, - ]; - } - if ( - frame.type === "system" && - frame.subtype === "task_notification" && - frame.task_id === SUBAGENT_TASK_ID - ) { - return [{ ...entry, frame: { ...frame, status: notificationStatus } }]; - } - return [entry]; - }), - ), - }), - }), - ); - } -}); - -function undecodableEvent(entry: ProviderReplayEntry): ProviderReplayEntry { - if (entry.type !== "emit_inbound") return entry; - const frame = entry.frame as { readonly event: Record }; - return { ...entry, frame: { ...frame, event: { ...frame.event, durable: "not-an-envelope" } } }; -} diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index b52639824d19..4e98e20949c9 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -167,6 +167,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray { + itRunsReplayFixtures("pi", (input) => + runReplayFixture({ ...input, harness: PiOrchestratorReplayHarness }), + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts new file mode 100644 index 000000000000..8c23e9c90181 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts @@ -0,0 +1,219 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import type { OrchestrationV2DomainEvent, ProviderReplayTranscript } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; +import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; +import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; +import { + assertProviderNativeSubagentRootTurns, + materializeFixtureInput, + type OrchestratorFixtureInput, + type ProviderOrchestratorReplayVariant, +} from "./fixtures/shared.ts"; +import { + runOrchestratorV2ProviderReplayScenario, + type OrchestratorV2ProviderReplayHarness, +} from "./ProviderReplayHarness.ts"; +import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts"; + +// Each driver's fixtures run from its own ReplayFixtures.integration.test.ts, +// so a focused run loads one adapter. A fixture for a driver missing here would +// silently never run, so loading this module fails instead. +const DRIVERS_WITH_REPLAY_FIXTURE_FILES = new Set([ + "acpRegistry", + "claudeAgent", + "codex", + "cursor", + "grok", + "muse", + "opencode", + "pi", +]); +for (const fixture of ORCHESTRATOR_REPLAY_FIXTURES) { + for (const provider of fixture.providers) { + if (!DRIVERS_WITH_REPLAY_FIXTURE_FILES.has(provider.driver)) { + throw new Error( + `${fixture.name}/${provider.driver} has no ReplayFixtures test file to run it.`, + ); + } + } +} + +export const readReplayFixtureTranscript = Effect.fn("readOrchestratorReplayTranscript")(function* ( + file: URL, +) { + return yield* readProviderReplayTranscript(file); +}, Effect.provide(NodeServices.layer)); + +function normalizeTestError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)); +} + +function transcriptEntriesThroughLabel( + transcript: ProviderReplayTranscript, + label: string | undefined, +): ProviderReplayTranscript { + if (label === undefined) { + return transcript; + } + const entryIndex = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === label, + ); + if (entryIndex === -1) { + throw new Error(`${transcript.scenario} is missing inbound transcript label ${label}.`); + } + return { + ...transcript, + entries: transcript.entries.slice(0, entryIndex + 1), + }; +} + +function isStreamingAssistantEvent(event: OrchestrationV2DomainEvent): boolean { + switch (event.type) { + case "node.updated": + return event.payload.kind === "assistant_message" && event.payload.status === "running"; + case "message.updated": + return event.payload.role === "assistant" && event.payload.streaming; + case "turn-item.updated": + return event.payload.type === "assistant_message" && event.payload.streaming; + default: + return false; + } +} + +export interface ReplayFixtureCase { + readonly fixtureName: string; + readonly buildInput: () => OrchestratorFixtureInput; + readonly driver: ProviderOrchestratorReplayVariant; + readonly transformTranscript?: (transcript: ProviderReplayTranscript) => ProviderReplayTranscript; +} + +export interface ReplayFixtureRun< + Transcript extends ProviderReplayTranscript, + Error, + WorkspaceError = never, +> extends ReplayFixtureCase { + readonly harness: OrchestratorV2ProviderReplayHarness; + /** Rewrites recorded workspace paths to the replay workspace, for drivers that record them. */ + readonly materializeWorkspace?: ( + transcript: ProviderReplayTranscript, + workspace: string, + ) => Effect.Effect; +} + +const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < + Transcript extends ProviderReplayTranscript, + Error, + WorkspaceError, +>(input: ReplayFixtureRun) { + const recordedTranscript = yield* readReplayFixtureTranscript(input.driver.transcriptFile); + const rawTranscript = input.transformTranscript?.(recordedTranscript) ?? recordedTranscript; + const replayTranscript = materializeReplayTranscriptRuntimeInstructions( + transcriptEntriesThroughLabel(rawTranscript, input.driver.transcriptEntriesThroughLabel), + { driver: input.driver.driver, model: input.driver.modelSelection.model }, + ); + const fixtureInput = input.buildInput(); + const workspace = yield* checkpointWorkspace(input.fixtureName, fixtureInput.workspaceFiles); + const transcript = yield* input.harness.decodeTranscript( + input.materializeWorkspace === undefined + ? replayTranscript + : yield* input.materializeWorkspace(replayTranscript, workspace), + ); + const materialized = yield* materializeFixtureInput({ + scenario: input.fixtureName, + fixtureInput, + driver: input.driver.driver, + modelSelection: input.driver.modelSelection, + }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); + const scenario = { + name: `${input.fixtureName}/${input.driver.driver}`, + transcript, + commands: materialized.commands, + steps: materialized.steps, + projectionThreadIds: materialized.projectionThreadIds, + runtimePolicyOverride: { + ...input.driver.runtimePolicyOverride, + cwd: workspace, + }, + }; + + const result = yield* runOrchestratorV2ProviderReplayScenario( + scenario, + input.harness, + input.driver.runContinuationWorker === true ? { runContinuationWorker: true } : {}, + ).pipe(provideDeterministicTestRuntime); + input.driver.assertOutput(result, transcript); + assertProviderNativeSubagentRootTurns(result); + const expectedAbsentWorkspacePaths = input.driver.expectedAbsentWorkspacePaths; + if (expectedAbsentWorkspacePaths !== undefined) { + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const relativePath of expectedAbsentWorkspacePaths) { + assert.isFalse( + yield* fs.exists(path.join(workspace, relativePath)), + `${input.fixtureName}/${input.driver.driver} must not create ${relativePath} in the replay workspace`, + ); + } + }).pipe(Effect.provide(NodeServices.layer)); + } + assert.isFalse( + result.domainEvents.some(isStreamingAssistantEvent), + "buffered delivery must not persist streaming assistant artifacts", + ); + const projectionThreadId = materialized.projectionThreadIds[0]; + assert.isDefined(projectionThreadId); + const projection = result.projections.get(projectionThreadId); + assert.isDefined(projection); + const latestRun = projection.runs.at(-1); + assert.deepEqual(latestRun?.modelSelection, input.driver.modelSelection); + if (projection.runs.some((run) => run.status === "completed")) { + const threadStartCheckpoint = projection.checkpoints.find( + (checkpoint) => checkpoint.ordinalWithinScope === 0 && checkpoint.appRunOrdinal === null, + ); + assert.isDefined( + threadStartCheckpoint, + "completed threads must retain an addressable thread-start checkpoint", + ); + assert.equal(threadStartCheckpoint.status, "ready"); + } + return result; +}); + +/** Replays one fixture variant through OrchestratorV2 and runs the shared output checks. */ +export function runReplayFixture< + Transcript extends ProviderReplayTranscript, + Error, + WorkspaceError = never, +>(input: ReplayFixtureRun) { + return runFixtureProvider(input).pipe(Effect.mapError(normalizeTestError), Effect.scoped); +} + +export function findReplayFixture(name: string) { + return ORCHESTRATOR_REPLAY_FIXTURES.find((fixture) => fixture.name === name); +} + +/** + * Registers one test per recorded fixture variant of `driver`. Call it inside the + * provider file's `describe`. `run` picks the harness for each variant. + */ +export function itRunsReplayFixtures( + driver: string, + run: (input: ReplayFixtureCase) => Effect.Effect, +) { + it.effect.each( + ORCHESTRATOR_REPLAY_FIXTURES.flatMap((fixture) => + fixture.providers + .filter((provider) => provider.driver === driver) + .map((provider) => [fixture.name, provider.driver, fixture, provider] as const), + ), + )("runs %s/%s through OrchestratorV2 using deterministic replay", ([, , fixture, provider]) => + run({ fixtureName: fixture.name, buildInput: fixture.buildInput, driver: provider }), + ); +} diff --git a/apps/server/src/project/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts index 4dbbb0121752..6a13af9ef944 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 9d8896cea0fa..411d84fb3859 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/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 00fabc7004fb..48529f28a9d5 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1241,6 +1241,59 @@ 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 }); + }), + ), + ); 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 a4d1e831d900..4b82338b1f57 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"; @@ -55,6 +56,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 +255,35 @@ 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))); + 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( diff --git a/apps/server/src/testUtils/shardWeights.json b/apps/server/src/testUtils/shardWeights.json index 185ee3e0d003..d0f1fafc6968 100644 --- a/apps/server/src/testUtils/shardWeights.json +++ b/apps/server/src/testUtils/shardWeights.json @@ -123,10 +123,16 @@ "src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts": 1.4, "src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts": 0.5, "src/orchestration-v2/runtimeLayer.test.ts": 2.8, - "src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts": 2.7, - "src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts": 44.6, + "src/orchestration-v2/testkit/AcpRegistryReplayFixtures.integration.test.ts": 4.8, + "src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts": 13.7, + "src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts": 6.4, + "src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts": 4.8, + "src/orchestration-v2/testkit/GrokReplayFixtures.integration.test.ts": 5.1, + "src/orchestration-v2/testkit/MuseReplayFixtures.integration.test.ts": 2.4, + "src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts": 5.8, "src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts": 2.2, "src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts": 2.1, + "src/orchestration-v2/testkit/PiReplayFixtures.integration.test.ts": 4.3, "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 16.1, "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 2.9, "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 5.1, diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 5c6176b28c06..57549bec067f 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -58,8 +58,12 @@ const packExecutableTargets = packExecutableTarget ] : 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 e5633cae585f..1fc7fd6eb5fa 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": { @@ -92,7 +92,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.3", + "@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 004eb9b53eeb..1b3bbee03464 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/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 89d52c87b282..311efa7e5f0c 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, @@ -199,6 +206,19 @@ function SidebarControl() { ); } +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", + }; +} + function NavigationHistoryShortcuts() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const routeThreadRef = useThreadRouteRef(); @@ -215,17 +235,8 @@ 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(), }, @@ -245,6 +256,67 @@ function NavigationHistoryShortcuts() { 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; +} + // Settings swaps the thread sidebar out of the tree. Keep the lightweight // project projection subscribed so returning to a draft never renders the // zero-project state while the environment snapshot reconnects. @@ -357,6 +429,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 8710dcc4df07..f0793fbc07db 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"; @@ -239,6 +185,33 @@ import { } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; 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 { 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); @@ -275,60 +248,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 = []; @@ -392,28 +311,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); @@ -474,779 +372,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; @@ -1255,652 +380,6 @@ 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, @@ -1911,419 +390,6 @@ const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies N 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 NO_MARKDOWN_EDITORS: ReadonlyArray = []; type MarkdownServerConfigFields = ReturnType; diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index ad6ff10291cc..f0aa2ab8b6b1 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,7 +1,16 @@ -import { findRecordedWorktreeSetup, mergeDirectPreviewAnnotation } from "./ChatView.logic"; +import { + boundAttachmentIds, + findRecordedWorktreeSetup, + mergeDirectPreviewAnnotation, + planComposerTurn, + snapshotComposerSend, + withMessageContext, +} from "./ChatView.logic"; import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { deriveProviderInstanceEntries } from "../providerInstances"; +import { ensureInlineContextReferences } from "../lib/composerContextReferences"; +import { terminalContextReference } from "../lib/composerContextRecords"; import { CommandId, EnvironmentId, @@ -1024,3 +1033,128 @@ describe("mergeDirectPreviewAnnotation", () => { 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"]); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 36f0feda2846..64abb91c5c3d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,8 @@ import * as Option from "effect/Option"; +import type { SetStateAction } from "react"; +import { applyClaudePromptEffortPrefix, resolvePromptInjectedEffort } from "@t3tools/shared/model"; +import { getProviderModelCapabilities } from "../providerModels"; +import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { ANTIGRAVITY_DEFAULT_MODEL, @@ -12,6 +16,7 @@ import { ProjectId, type MessageId, type ModelSelection, + type OrchestrationMessageContext, type OrchestrationV2ProjectedTurnItem, type PreviewAnnotationPayload, type ProviderInteractionMode, @@ -48,13 +53,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"; @@ -1335,3 +1359,254 @@ 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, + ]; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e8d6e034aa80..2946a5bb28b7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -23,9 +23,7 @@ import { 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"; @@ -44,11 +42,8 @@ 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 * as Schema from "effect/Schema"; @@ -60,7 +55,6 @@ import { AuthTerminalOperateScope, type AssistantCitation, type ChatFileAttachment, - CommandId, DEFAULT_MODEL, isProviderNativeSubagentThread, type EnvironmentId, @@ -75,24 +69,16 @@ import { type ThreadId, type RunId, type RunAttemptId, - type RuntimeRequestId, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, - ProviderDriverKind, - resolveProjectProviderInstanceEnabled, 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 { wasBootstrapThreadDeleted } 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, @@ -102,8 +88,6 @@ import { deriveThreadActivityRun, deriveLatestThreadRun, deriveThreadRuntime, - presentPendingBackgroundWork, - presentProviderGoal, } from "@t3tools/client-runtime/state/thread-execution"; import { resolveWorkflowOpening, @@ -127,10 +111,8 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { - applyClaudePromptEffortPrefix, createModelSelection, formatModelSlugName, - resolvePromptInjectedEffort, resolveSelectableModel, } from "@t3tools/shared/model"; import { @@ -138,7 +120,6 @@ import { projectScriptTerminalInput, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; -import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { latestUnheldRun, @@ -149,18 +130,8 @@ 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"; -import { - Fragment, - type SetStateAction, - useEffect, - useEffectEvent, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; @@ -198,14 +169,6 @@ 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 { @@ -213,13 +176,7 @@ import { buildPlanImplementationPrompt, resolvePlanFollowUpSubmission, } from "../proposedPlan"; -import { - DEFAULT_INTERACTION_MODE, - MAX_TERMINALS_PER_GROUP, - type ChatMessage, - type SessionPhase, - type Thread, -} from "../types"; +import { DEFAULT_INTERACTION_MODE, MAX_TERMINALS_PER_GROUP, type ChatMessage } from "../types"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isCommandPaletteOpen } from "../commandPaletteBus"; @@ -227,7 +184,6 @@ 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, @@ -262,7 +218,6 @@ import { 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"; @@ -270,26 +225,19 @@ 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 { registerFaviconProjectForThread } from "~/browserFaviconStore"; -import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -308,10 +256,6 @@ 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"; @@ -339,29 +283,15 @@ import { useComposerDraftStore, DraftId, } from "../composerDraftStore"; -import { - formatTerminalContextLabel, - type TerminalContextDraft, - type TerminalContextSelection, -} from "../lib/terminalContext"; +import { 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 { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; @@ -389,14 +319,11 @@ import { resolveThreadDetailRef, useEnvironmentSupportsServerBrowser, useProject, - useProjects, useThreadProjection, useThreadStatus, useThreadHistory, useQuietThreadShell, - useThreadShell, useChildThreadInputs, - useThreadRefs, useThreadVisibleTurnItems, useThreadCheckpointSummaries, readThreadShell, @@ -404,8 +331,6 @@ import { } from "../state/entities"; import { environmentShell, 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 { @@ -422,7 +347,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, @@ -479,45 +403,31 @@ 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, @@ -537,8 +447,6 @@ import { 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"; @@ -548,339 +456,69 @@ 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 { - buildVersionMismatchDismissalKey, - dismissServerUpdateFailure, - dismissVersionMismatch, - isServerUpdateFailureDismissed, - isVersionMismatchDismissed, - resolveServerConfigVersionMismatch, - resolveServerSelfUpdateCapability, - serverUpdateGuidance, - supportsDesktopAppUpdate, - supportsServerUpdateThreadContinuation, -} from "../versionSkew"; +import { Button } from "./ui/button"; import { useAssetUrls } from "../assets/assetUrls"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, threadContextsOutsideEnvironment, } from "@t3tools/shared/composerContextReferences"; -import { recallableComposerPrompt } from "./chat/composerPromptHistory"; 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 { + backgroundWorkBannerItem, + branchMismatchBannerItem, + childInputBannerItem, + goalBannerItem, +} from "./chat/ThreadActivityBanners"; +import { useTimelineFollow } from "./chat/useTimelineFollow"; +import { + type EnvironmentUnavailableState, + useSystemComposerBanners, +} from "./chat/useSystemComposerBanners"; +import { useDraftHeroLayoutTransition } from "./chat/useDraftHeroLayoutTransition"; +import { useLocalDispatchState } from "./chat/useLocalDispatchState"; +import { + draftFanoutSelectionsAtom, + draftSendState, + type MultipleModelTarget, + resolveMultipleModelTargets, + restoreComposerDraft, + rewindToMessage, + startFanoutThread, +} from "./chat/chatViewSend"; +import { + boundAttachmentIds, + chatActionErrorMessage, + formatOutgoingPrompt, + optimisticMessageAttachments, + planComposerTurn, + sendTitleFallbackLabels, + snapshotComposerSend, + withMessageContext, + isCompactCommandMessage, + type LocalThreadErrorEntry, + makeModelDisabledReason, + 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"; -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 = | { environmentId: EnvironmentId; @@ -907,151 +545,12 @@ interface TerminalLaunchContext { 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, @@ -1121,10 +620,6 @@ export default function ChatView(props: ChatViewProps) { 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]), ); @@ -1304,8 +799,6 @@ 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, ); @@ -1351,7 +844,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 @@ -1435,10 +927,6 @@ 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>({}); @@ -1467,31 +955,12 @@ export default function ChatView(props: ChatViewProps) { 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) @@ -1665,10 +1134,6 @@ export default function ChatView(props: ChatViewProps) { 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" && @@ -1744,13 +1209,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, @@ -1814,25 +1272,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); @@ -2028,7 +1467,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; @@ -2036,35 +1474,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) { @@ -2077,54 +1488,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( @@ -2293,176 +1656,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), @@ -2542,6 +1745,13 @@ 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; @@ -3251,188 +2461,32 @@ export default function ChatView(props: ChatViewProps) { }; 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( @@ -4080,615 +3134,112 @@ export default function ChatView(props: ChatViewProps) { }, ); }; - 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 } : {}), - }); - if (metadataUpdate) { - result = mapAtomCommandResult( - await updateThreadMetadata({ - environmentId, - input: { - threadId: input.threadId, - ...metadataUpdate, - }, - }), - () => undefined, - ); - if (result._tag === "Failure") { - return result; - } - } - - if (input.runtimeMode !== serverThread.runtimeMode) { - result = mapAtomCommandResult( - await setThreadRuntimeMode({ - environmentId, - input: { - threadId: input.threadId, - runtimeMode: input.runtimeMode, - createdAt: input.createdAt, - }, - }), - () => undefined, - ); - if (result._tag === "Failure") { - return result; - } - } - - if (input.interactionMode !== serverThread.interactionMode) { - result = mapAtomCommandResult( - await setThreadInteractionMode({ - environmentId, - input: { - threadId: input.threadId, - interactionMode: input.interactionMode, - createdAt: input.createdAt, - }, - }), - () => undefined, - ); - } - 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( - 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(); - } - }); - 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); - 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; + 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); } - 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; + let result: AtomCommandResult = AsyncResult.success(undefined); + const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: serverThread.modelSelection, + currentBranch: serverThread.branch, + ...(input.branch ? { nextBranch: input.branch } : {}), + }); + if (metadataUpdate) { + result = mapAtomCommandResult( + await updateThreadMetadata({ + environmentId, + input: { + threadId: input.threadId, + ...metadataUpdate, + }, + }), + () => undefined, + ); + if (result._tag === "Failure") { + return result; + } } - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - if (timelineScrollIntentRef.current === "toward-end") { - composerRef.current?.restoreAfterTimelineReachedEnd(); + + if (input.runtimeMode !== serverThread.runtimeMode) { + result = mapAtomCommandResult( + await setThreadRuntimeMode({ + environmentId, + input: { + threadId: input.threadId, + runtimeMode: input.runtimeMode, + createdAt: input.createdAt, + }, + }), + () => undefined, + ); + if (result._tag === "Failure") { + return result; } - 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(); + if (input.interactionMode !== serverThread.interactionMode) { + result = mapAtomCommandResult( + await setThreadInteractionMode({ + environmentId, + input: { + threadId: input.threadId, + interactionMode: input.interactionMode, + createdAt: input.createdAt, + }, + }), + () => undefined, + ); } - }, [ + return result; + }; + + const { + showScrollToBottom, + timelineLiveFollowEnabled, + cancelPositionRestoreRef, + 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, - 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, + timelineAnchorMessageId, + setTimelineAnchor, }); - 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]); useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; @@ -5297,35 +3848,13 @@ export default function ChatView(props: ChatViewProps) { }; 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 childInputBanner = childInputBannerItem({ + childThreadInputs, + parentAwaitingUser: Boolean( + activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput, + ), + onOpenThread: onOpenRelatedThreadStable, + }); const sendStandaloneCommand = async (text: string, failureMessage: string) => { if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return; @@ -5389,104 +3918,26 @@ export default function ChatView(props: ChatViewProps) { }; 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 goalBanner = + activeThread && activeGoal !== null + ? goalBannerItem({ + threadId: activeThread.id, + goal: activeGoal, + isWorking, + onSendCommand: sendStandaloneCommandStable, + }) + : null; - const backgroundWorkBannerItem = (() => { - const presentation = presentPendingBackgroundWork(activeBackgroundTasks); - if (presentation === null || !activeThread) { - return null; - } - return { - id: `background-work:${activeThread.id}`, - variant: "default", - priority: "activity", - icon: ( -
- {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} +
- {rightPanelPresent && !shouldUsePlanSidebarSheet && activeThreadRef ? ( - {panelToggleControls} + ) : null + } surfaces={renderedRightPanelSurfaces} environmentId={activeThreadRef.environmentId} activeSurfaceId={renderedRightPanelSurface?.id ?? null} @@ -8844,63 +7237,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} @@ -8924,76 +7261,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 df1230ae371c..b92d65b4430c 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"; @@ -663,3 +673,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 e778af853201..7dc909c07230 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,291 +173,68 @@ 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"; 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 [state, dispatch] = useReducer(reduceCommandPaletteUiState, { @@ -818,40 +571,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( @@ -866,7 +589,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); @@ -1121,39 +843,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, }); @@ -1161,42 +854,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 = @@ -1232,70 +893,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 @@ -1941,84 +1540,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 @@ -2066,21 +1588,7 @@ function OpenCommandPaletteDialog(props: { applyOpenIntent(openIntent); }, [openIntent, projectThreadItemCount]); - 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( @@ -2654,18 +2162,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); @@ -2772,55 +2268,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} @@ -3155,106 +2608,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 = @@ -3336,38 +2739,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 ? (
@@ -3400,42 +2786,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 000000000000..808536403a4b --- /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 000000000000..2b6d6497c0cb --- /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 000000000000..90373413be4f --- /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/CommandPaletteThemeItems.tsx b/apps/web/src/components/CommandPaletteThemeItems.tsx new file mode 100644 index 000000000000..818229d95066 --- /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 000000000000..e2732f541908 --- /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/ProjectCloneToastCoordinator.tsx b/apps/web/src/components/ProjectCloneToastCoordinator.tsx index 974138bf2f63..79c927d6e2d0 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 0ba589444def..4c1059eaeeb4 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -93,7 +93,7 @@ import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; 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 ea9bf5cec328..d19599225515 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, @@ -169,19 +121,14 @@ import { 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"; -import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; -import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildDraftActionMenuItems, @@ -189,7 +136,6 @@ import { threadActionRequiresOperate, } from "./threadActionMenu.logic"; import { - animateSidebarLayoutChanges, applySidebarThreadDrop, filterSidebarV2VisibleThreads, buildBulkTitleRegenerationContextMenuItem, @@ -197,7 +143,6 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, firstValidTimestampMs, - hasUnseenCompletion, isSidebarNestedLinkClick, isSidebarThreadWorking, isTrailingDoubleClick, @@ -209,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, @@ -241,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 +196,21 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom import { resolveDroppedFolderPath } from "./chat/folderDrop"; import { makeProjectFolderDropHandlers } from "./sidebar/projectFolderDrop"; 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, @@ -837,1882 +739,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; - // oxlint-disable-next-line react/purity -- presets are computed only while the menu is open, so each opening reads a fresh clock - 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 }, - }) - : 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, - }); - const topStatus = - status === "working" - ? { - label: thread.goal?.status === "active" ? "Goal" : "Working", - icon: "working" as const, - className: "text-info", - } - : status === "waiting" - ? { - label: "Waiting", - icon: "waiting" as const, - className: "text-muted-foreground", - } - : status === "approval" - ? { - label: "Approval", - icon: "approval" as const, - className: "text-warning-foreground", - } - : status === "input" - ? { - label: "Input", - 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 }); - }; - 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) { - // oxlint-disable-next-line react/purity -- the clock resyncs once per thread change, a stale value would misjudge the gate - 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/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f600b831c53e..106d84fd1257 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,15 +5,8 @@ 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, @@ -30,7 +22,6 @@ import type { EnvironmentId, ModelSelection, ProjectId, - PullRequestListInput, PreviewAnnotationPayload, ProviderApprovalDecision, ThreadContextRecord, @@ -41,7 +32,6 @@ import type { ScopedThreadRef, ServerProvider, ThreadId, - SnapShotSource, } from "@t3tools/contracts"; import { EnvironmentRequestInvalidError, @@ -69,7 +59,6 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model" import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { memo, - type ComponentProps, type ReactNode, useEffect, useEffectEvent, @@ -110,8 +99,6 @@ import { makeComposerMentionDragHandlers, } from "./composerMentionDrag"; import { - composerFloatingLayerProps, - useComposerMenuProps, isInsideCollapsedComposerControls, isInsideRestingComposerControlScope, } from "./composerEventScope"; @@ -140,8 +127,8 @@ import { } 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 { @@ -164,12 +151,7 @@ import { type ComposerBannerStackContent, type ComposerBannerStackItem, } from "./ComposerBannerStack"; -import { - compressImageForStash, - isHeicImageFile, - prepareImageForAttachment, -} from "../../lib/imageCompression"; -import { AttachmentImage } from "../media/AttachmentImage"; +import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression"; import { fileAttachmentTooLargeMessage, formatAttachmentSize, @@ -189,15 +171,11 @@ import { releaseAttachmentUpload, releaseDraftAttachment, releasePersistedAttachmentUpload, - retryAttachmentUpload, startAttachmentUpload, useAttachmentUploadStore, verifyStashedAttachmentUpload, } from "../../lib/attachmentUploadQueue"; -import { - attachmentUploadBlockReason, - formatAttachmentUploadProgress, -} from "../../lib/attachmentUploadState"; +import { attachmentUploadBlockReason } from "../../lib/attachmentUploadState"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; @@ -211,7 +189,6 @@ import { toKindScopedComposerContextId, } from "@t3tools/shared/composerContextReferences"; import { - getRestingComposerImagePreviewCounts, resolveRestingComposerControlsLayout, shouldAnimateComposerRestingTransition, shouldUseCompactComposerPrimaryActions, @@ -264,9 +241,8 @@ import { copiedComposerAttachmentFile, rememberCopiedComposerAttachments, } from "~/lib/copiedComposerAttachments"; -import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; -import { readThreadShell, useThreadShells } from "~/state/entities"; +import { readThreadShell } from "~/state/entities"; import { requestConfirmDialog } from "~/confirmDialog"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import type { @@ -280,15 +256,6 @@ import { readPreparedConnection } from "~/state/session"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { orchestrationEnvironment } from "~/state/orchestration"; 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, @@ -296,27 +263,26 @@ import { } 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 { + ComposerFooterModeControls, + ComposerFooterPrimaryActions, + ComposerModelPicker, +} from "./ComposerFooterControls"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; -import { - ComposerControl, - ComposerControlIcon, - ComposerControlSeparator, - ComposerSelectControl, -} from "./ComposerControl"; +import { ComposerControl, ComposerControlSeparator } 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, @@ -324,32 +290,22 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; -import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter"; 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, @@ -357,7 +313,6 @@ import { submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; -import { PierreEntryIcon } from "./PierreEntryIcon"; import { pendingDraftWork } from "./pendingDraftWork"; import { importComposerThreadAttachment, @@ -371,28 +326,8 @@ 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 ( -