diff --git a/.agents/skills/ios-debugger-agent/SKILL.md b/.agents/skills/ios-debugger-agent/SKILL.md index 7afa579383d9..8d204ff201bc 100644 --- a/.agents/skills/ios-debugger-agent/SKILL.md +++ b/.agents/skills/ios-debugger-agent/SKILL.md @@ -31,6 +31,8 @@ Avoid generic Mac window automation for switching among Simulator windows. Expli ## Choose build or launch +For T3 Code Mobile, run `node scripts/mobile-native-client.ts ensure ios ` from the checkout on the simulator host first. It checks the local Expo native fingerprint against the installed client and builds/installs when stale, missing, or unknown. Then launch with the intended Metro bundle. Authorized verification includes native builds and installs; do not stop because the existing client is old. Use `check` instead of `ensure` only when the user explicitly prohibits rebuilding or requests a read-only check. + - Use `build_run_sim` when native source, native dependencies, entitlements, or project configuration changed. - Use `test_sim` for the smallest relevant native test target or test cases; do not run an entire workspace test matrix routinely. - Use `launch_app_sim` when a compatible app is already installed and no native rebuild is needed. diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 3fcf94334fd6..afebcb7aa7d5 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -15,24 +15,28 @@ Inspect the host and the affected code before launching processes: - On macOS with Xcode, prefer one representative iOS Simulator when the change is cross-platform so the user can watch through serve-sim. Load and follow [`ios-debugger-agent`](../ios-debugger-agent/SKILL.md), and load [`ios-simulator-browser`](../ios-simulator-browser/SKILL.md) when live streaming is available. - On macOS, Linux, or Windows with the Android SDK, use one Android Emulator when Android is the affected surface or iOS tooling is unavailable. -- When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK, emulator, or dev-client prerequisite rather than claiming verification. +- When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK or emulator prerequisite rather than claiming verification. A missing development client is a build step, not a blocker. Do not treat unavailable iOS tooling as a blocker when Android is a valid representative target. -## Choose the lightest valid launch path +## Ensure a compatible native client -- For JavaScript, TypeScript, or asset-only changes, reuse a compatible installed development client and start Metro. Do not rebuild native code merely to load a new bundle. -- For native source, native dependencies, entitlements, config plugins, or generated project changes, rebuild the affected platform. -- Use `vp run ios:dev` or `vp run android:dev` only when an Expo clean prebuild is actually required; both commands regenerate the native project. -- If the user requested no native rebuild and no compatible app is installed, reuse an existing compatible `.app` or `.apk` artifact when available. Otherwise report the missing dev client instead of silently rebuilding. +Authorized mobile verification includes building and installing a development client. A missing, stale, or unknown native client is not a reason to skip verification or leave a PR in draft. Build and install it, then continue. Respect an explicit user instruction not to rebuild; otherwise do not ask for separate permission. -The development identity on both platforms is: +Run this from the checkout being tested, on the machine that hosts the selected simulator or emulator. Select and boot one explicit iOS UDID or Android emulator serial first: -- App: `T3 Code Dev` -- Bundle/package identifier: `com.t3tools.t3code.dev` -- URL scheme: `t3code-dev` +```bash +node scripts/mobile-native-client.ts ensure ios +node scripts/mobile-native-client.ts ensure android +``` + +`ensure` compares the checkout's local Expo development fingerprint and the installed app's binary contents against the last successful build record. It reuses a matching client; otherwise it runs a clean prebuild, builds and installs the development app, and records the successful result. It does not start Metro. Start Metro below after it succeeds. On hosts with an `agent-job` requirement, run the entire `ensure` command through that queue. + +For a read-only decision, use `check` in place of `ensure`. Exit 0 means compatible, 2 means build required, and 1 means an operational error. An app installed outside this helper is initially unknown and gets rebuilt once. Records are local to the simulator host under `~/.cache/t3code/native-clients` and work across checkouts. Do not copy records between machines or write them manually. + +A JavaScript-only diff, bundle identifier, app version, or recent install date does not prove native compatibility. Always check the whole checkout. Expo fingerprints are computed locally with `APP_VARIANT=development`; no EAS credentials or cloud build are required. Generated `ios/` and `android/` directories are excluded by `.fingerprintignore`, so edit native source modules or config plugins rather than generated output. -Bundle or package presence proves the correct variant, not native compatibility. Reuse it only when the current changes did not alter its Expo SDK, native dependencies, config plugins, entitlements, generated project, or native source. +The development identity is `T3 Code Dev`, bundle/package `com.t3tools.t3code.dev`, scheme `t3code-dev`. If a build fails, investigate the build error and fix the local prerequisites. Report the concrete failure if it cannot be resolved, not “no compatible client.” ## Start one disposable T3 environment @@ -98,10 +102,9 @@ Use `ios-debugger-agent` to select one UDID and set these XcodeBuildMCP session - Simulator ID: the selected UDID - Bundle ID: `com.t3tools.t3code.dev` -Check the installed client with: +After `ensure` succeeds, open the Metro URL: ```bash -xcrun simctl get_app_container com.t3tools.t3code.dev app xcrun simctl openurl ``` @@ -109,10 +112,9 @@ Accept the iOS confirmation prompt and dismiss the developer menu when it obscur ### Android launch -Select one running emulator serial from `adb devices` and check the installed client: +Use the emulator serial already checked by `ensure`: ```bash -adb -s shell pm path com.t3tools.t3code.dev adb -s reverse tcp: tcp: adb -s shell am start -W \ -a android.intent.action.VIEW \ diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 988e223d219f..2f9faba635e3 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -25,6 +25,7 @@ github:D3OXY github:dbalders github:eggfriedrice24 github:extoci +github:f-trycua github:flamboh github:FllipEis github:gbarros-dev diff --git a/.github/scripts/stage-preview-bundle.py b/.github/scripts/stage-preview-bundle.py new file mode 100644 index 000000000000..0b3a53285916 --- /dev/null +++ b/.github/scripts/stage-preview-bundle.py @@ -0,0 +1,76 @@ +"""Stage an untrusted preview ZIP without letting it replace packaging code.""" + +import shutil +import stat +import sys +import zipfile +from pathlib import Path + +ROOTS = ("server/dist", "desktop/dist-electron") +REQUIRED_FILES = { + "server/dist/bin.mjs", + "server/dist/client/index.html", + "desktop/dist-electron/main.cjs", +} +# The current bundle is about 32 MiB compressed. Bound extraction on the +# trusted runner even when the PR replaces the uploader entirely. +MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 +MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 +MAX_ENTRIES = 50_000 + + +def stage_bundle(archive: Path, destination: Path): + if archive.stat().st_size > MAX_ARCHIVE_BYTES: + raise ValueError("Preview archive is too large") + with zipfile.ZipFile(archive) as bundle: + entries = bundle.infolist() + if len(entries) > MAX_ENTRIES: + raise ValueError("Preview archive has too many entries") + if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES: + raise ValueError("Expanded preview bundle is too large") + seen = set() + files = set() + for entry in entries: + name = entry.filename.removesuffix("/") + parts = name.split("/") + # Reject ambiguous paths before normalization, including names + # that would alias on the macOS signing runner. + if ( + entry.orig_filename != entry.filename + or any(part in ("", ".", "..") for part in parts) + or any(char in name for char in "\\:") + or not name.isascii() + or any(ord(char) < 32 or ord(char) == 127 for char in name) + ): + raise ValueError(f"Unsafe preview path: {entry.filename!r}") + allowed = any(name.startswith(root + "/") for root in ROOTS) + if entry.is_dir(): + allowed |= any(root == name or root.startswith(name + "/") for root in ROOTS) + if not allowed: + raise ValueError(f"Unexpected preview path: {name!r}") + kind = stat.S_IFMT(entry.external_attr >> 16) + if kind not in (0, stat.S_IFDIR if entry.is_dir() else stat.S_IFREG): + raise ValueError(f"Non-regular preview entry: {name!r}") + if name.casefold() in seen: + raise ValueError(f"Duplicate preview path: {name!r}") + seen.add(name.casefold()) + if not entry.is_dir(): + files.add(name) + if not REQUIRED_FILES <= files: + raise ValueError("Preview bundle is missing required entry points") + # Validate all names before writing anything. This is a fresh directory + # outside the checkout; neither pre-existing links nor trusted files + # can be followed or overwritten. ZIP permissions are never restored. + destination.mkdir(parents=True, exist_ok=False) + for entry in entries: + target = destination / entry.filename + if entry.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with bundle.open(entry) as source, target.open("xb") as output: + shutil.copyfileobj(source, output) + + +if __name__ == "__main__": + stage_bundle(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/.github/scripts/stage-preview-bundle.test.py b/.github/scripts/stage-preview-bundle.test.py new file mode 100644 index 000000000000..5957279c8885 --- /dev/null +++ b/.github/scripts/stage-preview-bundle.test.py @@ -0,0 +1,97 @@ +import importlib.util +import stat +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location( + "stage_preview_bundle", Path(__file__).with_name("stage-preview-bundle.py") +) +staging = importlib.util.module_from_spec(spec) +spec.loader.exec_module(staging) + + +class StagePreviewBundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.archive = self.root / "bundle.zip" + self.destination = self.root / "staged" + + def bundle(self, extra=(), missing=None): + with zipfile.ZipFile(self.archive, "w") as bundle: + for name in sorted(staging.REQUIRED_FILES - {missing}): + bundle.writestr(name, b"bundle data, never executed") + for name, content in extra: + bundle.writestr(name, content) + + def stage(self): + staging.stage_bundle(self.archive, self.destination) + + def test_preserves_valid_bundle_layout_and_bytes(self): + self.bundle([("server/", b""), ("server/dist/", b""), + ("desktop/dist-electron/chunks/helper.cjs", b"chunk")]) + self.stage() + for name in staging.REQUIRED_FILES: + self.assertEqual((self.destination / name).read_bytes(), b"bundle data, never executed") + self.assertEqual((self.destination / "desktop/dist-electron/chunks/helper.cjs").read_bytes(), b"chunk") + + def test_rejects_builder_overwrite_and_unsafe_paths_before_writing(self): + for name in [ + "desktop/node_modules/electron-builder/cli.js", + "desktop/package.json", + "server/dist/../../desktop/package.json", + "../package.json", + "/server/dist/absolute", + "server/dist/./alias", + "server/dist//alias", + "server/dist/back\\slash", + "server/dist/file:stream", + "server/dist/BIN.MJS", + ]: + with self.subTest(name=name): + self.bundle([(name, b"untrusted")]) + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_rejects_links_and_special_files(self): + for mode in [stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR]: + with self.subTest(mode=mode): + entry = zipfile.ZipInfo("server/dist/link") + entry.create_system = 3 + entry.external_attr = (mode | 0o777) << 16 + self.bundle([(entry, b"../../../desktop/node_modules")]) + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_requires_entry_points(self): + self.bundle(missing="desktop/dist-electron/main.cjs") + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_bounds_archive_size_expanded_size_and_entry_count(self): + for limit in ["MAX_ARCHIVE_BYTES", "MAX_EXPANDED_BYTES", "MAX_ENTRIES"]: + with self.subTest(limit=limit), patch.object(staging, limit, 1): + self.bundle() + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_refuses_existing_destination(self): + self.bundle() + self.destination.mkdir() + sentinel = self.destination / "trusted" + sentinel.write_text("untouched") + with self.assertRaises(FileExistsError): + self.stage() + self.assertEqual(sentinel.read_text(), "untouched") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae44acd8190f..0d8677a28176 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,9 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test preview artifact validation + run: python3 -B .github/scripts/stage-preview-bundle.test.py + - name: Test nightly release checks run: node --test .github/scripts/check-nightly-release.test.cjs diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml new file mode 100644 index 000000000000..4d141e327c1f --- /dev/null +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -0,0 +1,575 @@ +name: Desktop macOS Preview Publish + +# Trusted half of the macOS preview. Runs from main with secrets and a write +# token, so it must never execute PR code: the PR's JS bundle is only data that +# gets packaged into the app. Everything that runs here (packaging, signing, +# notarization, publishing) is main's code. +# +# Gate, in order: the completed build run belongs to an open PR that still +# carries the preview:mac label and whose head is the built commit, and the PR +# author is trusted by the vouch list. A maintainer applying the label alone is +# not enough, since the bundle gets signed with the Developer ID certificate. +# +# The label is consumed here once the gate passes, so it only ever covers the +# one commit a maintainer applied it to. A later push builds nothing until the +# label is applied again. + +on: + workflow_run: + workflows: [Desktop macOS Preview] + types: [completed] + # The way out: closing the PR deletes its download, and removing the label + # before it is consumed cancels the preview. pull_request_target gives this a + # write token for fork PRs; it never checks out PR code. + pull_request_target: + types: [closed, unlabeled] + +permissions: + contents: read + +jobs: + resolve: + name: Verify preview eligibility + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + # The build workflow completes for every PR push (its label gate is on the + # job), so this runs often and usually finds nothing. Keep it cheap. + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read + contents: read + # write only to consume the label; nothing here runs PR code. + pull-requests: write + outputs: + eligible: ${{ steps.gate.outputs.eligible }} + pr_number: ${{ steps.pr.outputs.pr_number }} + head_sha: ${{ steps.pr.outputs.head_sha }} + version: ${{ steps.version.outputs.version }} + clerk_publishable_key: ${{ steps.version.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ steps.version.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ steps.version.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ steps.version.outputs.relay_url }} + steps: + - id: pr + name: Resolve the pull request behind the build + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const run = context.payload.workflow_run; + const { owner, repo } = context.repo; + + // The build workflow also completes (with every job skipped) for + // label events that are not the preview label. Only a run that + // produced a bundle is worth resolving. + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: 100, + }); + const bundles = artifacts.filter((artifact) => artifact.name === "js-bundle" && !artifact.expired); + if (bundles.length !== 1) { + core.info(`Expected one js-bundle artifact; found ${bundles.length}. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + + // workflow_run.pull_requests is empty for fork PRs, so resolve the + // PR from the built commit instead and require exactly one open PR + // from the same head repository and branch. The build baked its + // PR number into the version, so two candidates would mean the + // asset name could belong to either. + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: run.head_sha, per_page: 100 }, + ); + const matching = associated.filter( + (candidate) => + candidate.state === "open" && + candidate.head.sha === run.head_sha && + candidate.head.ref === run.head_branch && + candidate.head.repo?.full_name === run.head_repository?.full_name, + ); + if (matching.length !== 1) { + core.info(`Expected one open PR for ${run.head_sha}; found ${matching.length}. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: matching[0].number, + }); + + if (pull.state !== "open") { + core.info(`PR #${pull.number} is not open. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + if (pull.head.sha !== run.head_sha) { + core.info(`PR #${pull.number} moved to ${pull.head.sha} after ${run.head_sha} was built. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + if (!pull.labels.some((label) => label.name === "preview:mac")) { + core.info(`PR #${pull.number} no longer carries the preview:mac label. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + + core.setOutput("artifact_id", String(bundles[0].id)); + core.setOutput("eligible", "true"); + core.setOutput("pr_number", String(pull.number)); + core.setOutput("head_sha", pull.head.sha); + core.setOutput("author", pull.user.login); + + # Reads VOUCHED.td from the default branch through the API, so a PR + # cannot vouch for itself. + - id: vouch + name: Check PR author trust + if: steps.pr.outputs.eligible == 'true' + uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1 + with: + user: ${{ steps.pr.outputs.author }} + allow-fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # The label authorized exactly this build, so take it now, before the + # long signing job. Removing it with GITHUB_TOKEN does not fire the + # unlabeled cleanup below (workflow-token events never start runs), so + # the download this run publishes survives. If a maintainer removed the + # label first, that removal wins: the 404 makes this run ineligible. + - id: consume + name: Consume the preview label + if: steps.pr.outputs.eligible == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + with: + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.PR_NUMBER), + name: "preview:mac", + }); + core.setOutput("consumed", "true"); + } catch (error) { + if (error.status !== 404) throw error; + core.info("The preview:mac label was removed before this build could consume it. Skipping."); + core.setOutput("consumed", "false"); + } + + - id: gate + name: Decide eligibility + shell: bash + env: + PR_ELIGIBLE: ${{ steps.pr.outputs.eligible }} + LABEL_CONSUMED: ${{ steps.consume.outputs.consumed }} + VOUCH_STATUS: ${{ steps.vouch.outputs.status }} + AUTHOR: ${{ steps.pr.outputs.author }} + run: | + set -euo pipefail + if [[ "$PR_ELIGIBLE" != "true" || "$LABEL_CONSUMED" != "true" ]]; then + echo "eligible=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + case "$VOUCH_STATUS" in + bot|collaborator|vouched) + echo "Author $AUTHOR is trusted ($VOUCH_STATUS)." + echo "eligible=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Author $AUTHOR is not vouched ($VOUCH_STATUS). Add them to .github/VOUCHED.td to allow signed previews." + echo "eligible=false" >> "$GITHUB_OUTPUT" + ;; + esac + + # Same inputs as the build workflow, read from the built commit through + # the contents API as data: the desktop manifest's base version plus the + # build run's number reproduces the version baked into the bundle, and + # .env.example holds the public T3 Connect identifiers the bundle was + # compiled with, which the signed app's passkey entitlement must match. + # Both are validated before they reach a file name or an entitlement. + - id: version + name: Resolve preview version and public configuration + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + BUILD_RUN_NUMBER: ${{ github.event.workflow_run.run_number }} + run: | + set -euo pipefail + head_file() { + gh api "repos/${GITHUB_REPOSITORY}/contents/$1?ref=${HEAD_SHA}" --jq '.content' | base64 --decode + } + + base_version="$(head_file apps/desktop/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" + # The committed desktop version is always a plain X.Y.Z; every + # prerelease identifier is added by a release run. Anything else + # would also let a foreign -pr.N. marker into the asset name, which + # is what publish and cleanup key on. + if [[ ! "$base_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unexpected desktop version '$base_version' at $HEAD_SHA; expected X.Y.Z." >&2 + exit 1 + fi + echo "version=${base_version}-pr.${PR_NUMBER}.${BUILD_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + + head_file .env.example > "$RUNNER_TEMP/head.env.example" + for key in clerk_publishable_key:T3CODE_CLERK_PUBLISHABLE_KEY clerk_jwt_template:T3CODE_CLERK_JWT_TEMPLATE clerk_cli_oauth_client_id:T3CODE_CLERK_CLI_OAUTH_CLIENT_ID relay_url:T3CODE_RELAY_URL; do + output="${key%%:*}" + name="${key##*:}" + value="$(sed -n "s/^${name}=//p" "$RUNNER_TEMP/head.env.example" | head -n 1)" + if [[ ! "$value" =~ ^[A-Za-z0-9._:/-]+$ ]]; then + echo "$name is missing or malformed in .env.example at $HEAD_SHA." >&2 + exit 1 + fi + echo "${output}=${value}" >> "$GITHUB_OUTPUT" + done + + # Only the default-branch revision that owns this workflow supplies the + # validator. Never check out the PR in a workflow_run job. + - name: Checkout trusted artifact validator + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + CHECKOUT_REF: ${{ github.sha }} + GIT_TERMINAL_PROMPT: "0" + # Anonymous fetch avoids checkout's credential cleanup, which fails on + # orphaned gitlinks in .repos even when that directory is excluded. + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set .github/scripts + git checkout --detach FETCH_HEAD + + # Fetch the archive as bytes. Extracting it over the checkout, even with + # download-artifact, could replace code that runs with signing secrets. + - name: Download and validate PR JS bundle + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/js-bundle.zip" + python3 .github/scripts/stage-preview-bundle.py "$RUNNER_TEMP/js-bundle.zip" "$RUNNER_TEMP/js-bundle" + + # Only validated bundle files cross into the signing job's artifact. + - name: Stage JS bundle for packaging + if: steps.gate.outputs.eligible == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: js-bundle + path: ${{ runner.temp }}/js-bundle + if-no-files-found: error + # Re-running this workflow re-uploads under the same run. + overwrite: true + retention-days: 1 + + build: + name: Package and sign macOS arm64 preview + needs: resolve + if: needs.resolve.outputs.eligible == 'true' + concurrency: + group: desktop-macos-preview-${{ needs.resolve.outputs.pr_number }}-build + cancel-in-progress: true + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + with: + version: ${{ needs.resolve.outputs.version }} + ref: ${{ github.sha }} + release_channel: preview + relay_client_tracing: false + clerk_publishable_key: ${{ needs.resolve.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.resolve.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.resolve.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.resolve.outputs.relay_url }} + label: macOS arm64 preview + runner: blacksmith-12vcpu-macos-26 + platform: mac + target: dmg + arch: arm64 + rust_target: aarch64-apple-darwin + resource_key: darwin-arm64 + cli_archive: false + + # Release assets download without a GitHub account, unlike workflow + # artifacts. All preview DMGs live on one rolling prerelease tagged + # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a + # build never notifies release watchers. + publish: + name: Publish anonymous download + needs: [resolve, build] + if: needs.resolve.outputs.eligible == 'true' && needs.build.result == 'success' + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + # Its own group, so a publish never cancels a newer commit's signing job + # (they would share the build group) and is never cancelled mid-upload. + # preview_eligible's head check keeps a superseded publish from landing. + concurrency: + group: desktop-macos-preview-${{ needs.resolve.outputs.pr_number }}-publish + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - name: Download macOS artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: desktop-mac-arm64 + path: release + + - id: upload + name: Upload DMG to the rolling preview release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} + HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # True while the PR is open and still points at the commit this + # build came from. The label was consumed in resolve, so it is not + # part of this check. A push does not cancel an already-running + # signing job, so this is what keeps a superseded commit's DMG off + # the release. + preview_eligible() { + [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,headRefOid \ + --jq '.state + " " + .headRefOid')" == "OPEN $HEAD_SHA" ]] + } + + # The build ran for many minutes. If the PR closed or moved on + # meanwhile, cleanup already ran in its own concurrency group or a + # newer build owns the asset, so publishing now would resurrect a + # deleted download or clobber a newer one. + if ! preview_eligible; then + echo "PR closed or head moved while building. Skipping publish." + exit 0 + fi + + shopt -s nullglob + dmg_files=(release/*.dmg) + if (( ${#dmg_files[@]} != 1 )); then + printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 + exit 1 + fi + dmg_path="${dmg_files[0]}" + + # Requiring this PR's marker keeps a build from clobbering or + # deleting another PR's asset, since those names carry a different + # -pr.N. marker. + if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then + echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2 + exit 1 + fi + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + # "|| true" tolerates a concurrent publish job creating the + # release between the check and the create. + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$DEFAULT_BRANCH" \ + --prerelease \ + --title "Desktop preview builds" \ + --notes "Rolling desktop builds from pull requests with a preview label. Each download is removed when its pull request closes or loses the label. Install stable builds from the latest release instead." \ + || true + fi + + # Keep one DMG per PR: drop this PR's older builds first. The + # trailing dot keeps -pr.12. from matching -pr.123. builds. + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber + + # Re-check after uploading. A cleanup run that started during the + # upload listed assets before ours existed, so it cannot delete it. + # Whichever writer acts last sees the final PR state; if the preview + # became ineligible, delete what we just uploaded. + if ! preview_eligible; then + gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset was already removed by a concurrent run." + echo "PR closed or head moved during upload. Removed the download." + exit 0 + fi + + echo "dmg_name=$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" + echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" + + - name: Comment download link + if: steps.upload.outputs.download_url != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} + DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} + HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} + PREVIEW_VERSION: ${{ needs.resolve.outputs.version }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + if (pullRequest.head.sha !== process.env.HEAD_SHA || pullRequest.state !== "open") { + core.info("Skipping the outdated macOS preview comment."); + return; + } + + const marker = ""; + const body = [ + marker, + "### macOS preview", + "", + `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, + "", + `Version: ${process.env.PREVIEW_VERSION}`, + `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, + "", + "Signed and notarized, with T3 Connect enabled. The app bundle (server, web client, Electron main) is built from this PR; packaging, native helpers, and desktop dependencies come from `main`.", + "", + "No GitHub sign-in is needed. The download stays available until this PR closes. The `preview:mac` label was consumed by this build; a maintainer applies it again to build a newer commit.", + ].join("\n"); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } + + cleanup: + name: Remove preview download + # A published preview no longer carries the label (resolve consumed it), + # so every close must look for assets; the -pr.N. filter below makes + # that a cheap no-op for PRs that never had one. A manual unlabel before + # the build consumed it withdraws the request and drops any older + # download too. + if: >- + github.event_name == 'pull_request_target' && + (github.event.action == 'closed' || + (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac')) + # Runs on every PR close and usually finds nothing. Keep it cheap. + runs-on: ubuntu-24.04 + timeout-minutes: 10 + # Cleanup runs must complete: a close event right after an unlabel queues + # behind the running cleanup instead of canceling it mid-delete. + concurrency: + group: desktop-macos-preview-${{ github.event.pull_request.number }}-cleanup + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - id: delete + name: Delete this PR's preview assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # A stale cleanup must not delete a download that became valid + # again. If the PR is open and labeled once more, the next publish + # owns this PR's assets and replaces them itself. + if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,labels \ + --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then + echo "PR is open and labeled again. Skipping cleanup." + echo "removed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "removed=true" >> "$GITHUB_OUTPUT" + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "No preview release exists. Nothing to clean up." + exit 0 + fi + + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + - name: Mark the preview comment as removed + if: steps.delete.outputs.removed == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const marker = ""; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find( + (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker), + ); + if (!existing) { + return; + } + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: [ + marker, + "### macOS preview", + "", + "The preview download was removed because this PR closed or the preview label was removed.", + ].join("\n"), + }); diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 7875aec6f36b..43b6c8220aaf 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -1,74 +1,67 @@ name: Desktop macOS Preview +# Untrusted half of the macOS preview. This runs PR code (including fork PRs) +# with a read-only token and no secrets, and only produces the JS bundle. The +# trusted half, desktop-macos-preview-publish.yml, runs on workflow_run from +# main, verifies the PR author is vouched, then packages, signs, notarizes, and +# publishes the bundle without ever executing it. +# +# The label is a one-shot request for the commit it was applied to, not a +# standing subscription: the trusted half removes it once this run completes, +# and later pushes do not build until a maintainer applies it again. Each +# signed preview is therefore an explicit per-commit decision. +# +# Closing the PR is handled by the publish workflow too, since deleting the +# download needs a write token. + on: pull_request: - types: [labeled, unlabeled, synchronize, reopened, closed] + types: [labeled] permissions: contents: read -# Build events and cleanup events use separate groups: a push must cancel a -# stale in-flight build, but must never cancel a cleanup run mid-delete. The -# publish job re-checks PR state before uploading to cover the reverse race. concurrency: - group: desktop-macos-preview-${{ github.event.pull_request.number }}-${{ contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && 'cleanup' || 'build' }} - # Cleanup runs must complete (a close event right after an unlabel queues - # behind the running cleanup instead of canceling it mid-delete), and events - # that skip the build job, such as adding an unrelated label, must not - # cancel an in-flight build either. - cancel-in-progress: ${{ !contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') }} + group: desktop-macos-preview-${{ github.event.pull_request.number }} + # Adding an unrelated label skips the job and must not cancel a build. + cancel-in-progress: ${{ github.event.label.name == 'preview:mac' }} jobs: - # Builds run PR code, so this job keeps a read-only token. Publishing to the - # release happens in the publish job below, which never checks out PR code. build: - name: Build macOS Apple Silicon preview - if: >- - github.event.action != 'closed' && - github.event.action != 'unlabeled' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(github.event.pull_request.labels.*.name, 'preview:mac') && - (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') - runs-on: blacksmith-12vcpu-macos-26 + name: Build preview JS bundle + if: github.event.label.name == 'preview:mac' + runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 - outputs: - dmg_name: ${{ steps.build.outputs.dmg_name }} - version: ${{ steps.version.outputs.version }} steps: + # This repository is public, so Git needs no credentials. checkout's + # credential cleanup runs submodule foreach even with submodules disabled, + # which fails on the orphaned gitlinks in our vendored .repos tree. - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + shell: bash + env: + CHECKOUT_REF: ${{ github.event.pull_request.head.sha }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: true run-install: false - - name: Install desktop dependencies - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - path: native/resource-monitor/target/aarch64-apple-darwin/release/t3-resource-monitor - key: resource-monitor-aarch64-apple-darwin-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - - - name: Setup Rust - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin + - name: Install bundle dependencies + run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - - id: version - name: Set preview version and public configuration + # The publish workflow derives the same version from this run's number, + # so the version baked into the bundle matches the packaged app. + - name: Set preview version and public configuration shell: bash env: PR_NUMBER: ${{ github.event.pull_request.number }} @@ -76,286 +69,26 @@ jobs: set -euo pipefail base_version="$(node -p "require('./apps/desktop/package.json').version")" - preview_version="${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" - node scripts/update-release-package-versions.ts "$preview_version" + node scripts/update-release-package-versions.ts "${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" + # Public T3 Connect identifiers (Clerk publishable key, relay URL). cp .env.example .env - echo "version=$preview_version" >> "$GITHUB_OUTPUT" - - - id: build - name: Build unsigned macOS DMG - shell: bash - env: - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} - PREVIEW_VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail + - uses: ./.github/actions/setup-apt-mirrors - vp run dist:desktop:artifact \ - --platform mac \ - --target dmg \ - --arch arm64 \ - --build-version "$PREVIEW_VERSION" \ - --verbose + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - shopt -s nullglob - dmg_files=(release/*.dmg) - if (( ${#dmg_files[@]} != 1 )); then - printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 - exit 1 - fi - printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT" + - name: Build JS bundle + run: vp run build:desktop - # archive: false uploads the file as its own artifact named after the - # file, so the publish job downloads by *.dmg pattern, not by name. - - name: Upload macOS DMG - uses: actions/upload-artifact@v7 + # Same layout as release.yml's js-bundle so release-desktop.yml can + # package it unchanged. + - name: Upload JS bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - path: release/*.dmg + name: js-bundle + path: | + apps/server/dist + apps/desktop/dist-electron if-no-files-found: error - archive: false - overwrite: true - retention-days: 7 - - # Release assets download without a GitHub account, unlike workflow - # artifacts. All preview DMGs live on one rolling prerelease tagged - # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a - # build never notifies release watchers. This job holds the write token and - # only handles the artifact the build job produced; it never runs PR code. - publish: - name: Publish anonymous download - needs: build - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - name: Download macOS DMG - uses: actions/download-artifact@v8 - with: - pattern: "*.dmg" - merge-multiple: true - path: release - - - id: upload - name: Upload DMG to the rolling preview release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - - tag="desktop-preview" - - # True while the PR is open and still carries the preview label. - preview_eligible() { - [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --json state,labels \ - --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]] - } - - # The build ran for many minutes. If the PR closed or lost the label - # meanwhile, cleanup already ran in its own concurrency group, so - # publishing now would resurrect a deleted download. - if ! preview_eligible; then - echo "PR closed or preview label removed while building. Skipping publish." - exit 0 - fi - - dmg_path="$(find release -type f -name '*.dmg' -print -quit)" - if [[ -z "$dmg_path" ]]; then - echo "No DMG found in the downloaded artifact." >&2 - exit 1 - fi - - # The filename comes out of the build, which runs PR code. Requiring - # this PR's marker keeps a build from clobbering or deleting another - # PR's asset, since those names carry a different -pr.N. marker. - if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then - echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2 - exit 1 - fi - - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - # "|| true" tolerates a concurrent publish job creating the - # release between the check and the create. - gh release create "$tag" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$DEFAULT_BRANCH" \ - --prerelease \ - --title "Desktop preview builds" \ - --notes "Rolling unsigned desktop builds from pull requests with a preview label. Each download is removed when its pull request closes or loses the label. Install stable builds from the latest release instead." \ - || true - fi - - # Keep one DMG per PR: drop this PR's older builds first. The - # trailing dot keeps -pr.12. from matching -pr.123. builds. - gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ - | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ - | while read -r asset; do - gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset $asset was already removed by a concurrent run." - done - - gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber - - # Re-check after uploading. A cleanup run that started during the - # upload listed assets before ours existed, so it cannot delete it. - # Whichever writer acts last sees the final PR state; if the preview - # became ineligible, delete what we just uploaded. - if ! preview_eligible; then - gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset was already removed by a concurrent run." - echo "PR closed or preview label removed during upload. Removed the download." - exit 0 - fi - - echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" - - - name: Comment download link - if: steps.upload.outputs.download_url != '' - uses: actions/github-script@v8 - env: - DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} - DMG_NAME: ${{ needs.build.outputs.dmg_name }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PREVIEW_VERSION: ${{ needs.build.outputs.version }} - with: - script: | - const { data: pullRequest } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - }); - if ( - pullRequest.head.sha !== process.env.HEAD_SHA || - pullRequest.state !== "open" || - !pullRequest.labels.some((label) => label.name === "preview:mac") - ) { - core.info("Skipping the outdated macOS preview comment."); - return; - } - - const marker = ""; - const body = [ - marker, - "### macOS preview", - "", - `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, - "", - `Version: ${process.env.PREVIEW_VERSION}`, - `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, - "", - "Unsigned build. Clear quarantine before opening:", - "```sh", - `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`, - "```", - "", - "No GitHub sign-in is needed. The download stays available until this PR closes or the preview label is removed.", - ].join("\n"); - - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body, - }); - } - - # The way out: closing the PR or removing the label deletes its DMG from the - # rolling release and updates the PR comment to say so. - cleanup: - name: Remove preview download - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - ((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:mac')) || - (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac')) - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - id: delete - name: Delete this PR's preview assets - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - tag="desktop-preview" - - # A stale cleanup must not delete a download that became valid - # again. If the PR is open and labeled once more, the next publish - # owns this PR's assets and replaces them itself. - if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --json state,labels \ - --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then - echo "PR is open and labeled again. Skipping cleanup." - echo "removed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "removed=true" >> "$GITHUB_OUTPUT" - - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "No preview release exists. Nothing to clean up." - exit 0 - fi - - gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ - | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ - | while read -r asset; do - gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset $asset was already removed by a concurrent run." - done - - - name: Mark the preview comment as removed - if: steps.delete.outputs.removed == 'true' - uses: actions/github-script@v8 - with: - script: | - const marker = ""; - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - if (!existing) { - return; - } - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: [ - marker, - "### macOS preview", - "", - "The preview download was removed because this PR closed or the preview label was removed.", - ].join("\n"), - }); + retention-days: 1 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 5d790e49a061..c374a8692db6 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -8,6 +8,33 @@ name: Release desktop build on: workflow_call: + secrets: + CSC_LINK: + required: false + CSC_KEY_PASSWORD: + required: false + APPLE_API_KEY: + required: false + APPLE_API_KEY_ID: + required: false + APPLE_API_ISSUER: + required: false + MACOS_PROVISIONING_PROFILE: + required: false + AZURE_TENANT_ID: + required: false + AZURE_CLIENT_ID: + required: false + AZURE_CLIENT_SECRET: + required: false + AZURE_TRUSTED_SIGNING_ENDPOINT: + required: false + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: + required: false + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: + required: false + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: + required: false inputs: label: required: true @@ -46,6 +73,12 @@ on: release_channel: required: true type: string + # Whether a `relay-client-tracing-config` artifact from the production + # relay state is expected. PR previews carry no tracing config. + relay_client_tracing: + required: false + default: true + type: boolean clerk_publishable_key: required: true type: string @@ -73,17 +106,24 @@ jobs: T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ inputs.relay_url }} steps: + # This repository is public, so Git needs no credentials. checkout's + # credential cleanup runs submodule foreach even with submodules disabled, + # which fails on the orphaned gitlinks in our vendored .repos tree. - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + shell: bash + env: + CHECKOUT_REF: ${{ inputs.ref }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: ${{ inputs.platform != 'win' }} @@ -97,7 +137,7 @@ jobs: - name: Cache Windows packages if: inputs.platform == 'win' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.package_cache_path.outputs.path }} key: windows-release-packages-v1-${{ inputs.arch }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -106,7 +146,7 @@ jobs: # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification continue-on-error: true - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata @@ -118,7 +158,7 @@ jobs: - name: Cache resource monitor id: resource_monitor_cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }} key: resource-monitor-${{ inputs.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} @@ -126,7 +166,7 @@ jobs: - name: Cache Linux capture helpers if: inputs.platform == 'linux' id: capture_helper_cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot @@ -135,17 +175,20 @@ jobs: - name: Setup Rust if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: + toolchain: stable targets: ${{ inputs.rust_target }} - name: Download relay client tracing config - uses: actions/download-artifact@v8 + if: inputs.relay_client_tracing + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing - name: Load relay client tracing config + if: inputs.relay_client_tracing shell: bash run: | config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" @@ -160,7 +203,7 @@ jobs: # ancestor of its paths), so extracting into `apps` restores # apps/server/dist and apps/desktop/dist-electron at their build paths. - name: Download JS bundle - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: js-bundle path: apps @@ -169,7 +212,7 @@ jobs: # Windows desktop embeds the same-arch archive the release attaches. - name: Download Linux CLI archive for WSL if: inputs.platform == 'win' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: cli-linux-${{ inputs.arch }} path: wsl-runtime @@ -450,7 +493,7 @@ jobs: - name: Upload CLI archive if: inputs.cli_archive - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* @@ -514,14 +557,14 @@ jobs: cp "$source_path" "$target_dir/$binary_name" - name: Upload build artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-${{ inputs.platform }}-${{ inputs.arch }} path: release-publish/* if-no-files-found: error - name: Upload resource monitor - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: resource-monitor-${{ inputs.resource_key }} path: resource-monitor-publish/${{ inputs.resource_key }}/* diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 8ed88559cfd8..e38c0b040af0 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -12,14 +12,11 @@ include: - "infra/**/*.ts" exclude: - "**/*.test.ts" -labels: - - vouch:trusted - - macroscope-review requires: - Check maxBudgetPerRun: 5 maxBudgetPerPR: 25 -conclusion: failure +conclusion: neutral showToolCalls: true --- diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 87285d7b0881..b90c81ab0a49 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -11,14 +11,11 @@ include: - "apps/web/src/**/*.css" exclude: - "apps/web/src/**/*.test.tsx" -labels: - - vouch:trusted - - macroscope-review requires: - Check maxBudgetPerRun: 2 maxBudgetPerPR: 10 -conclusion: failure +conclusion: neutral --- # UI consistency review diff --git a/AGENTS.md b/AGENTS.md index ccf1fdc1d85c..38df1e94fa8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,8 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. +For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. + ## Pull requests - Never make a PR unless the developer explicitly asks you to do so. diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index eedfff9744f8..48f66ab76b56 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -116,6 +116,8 @@ export function makeDevelopmentEnvironmentScript(environment) { ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH], ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL], ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS], + ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS], + ["T3CODE_OTLP_PROTOCOL", environment.T3CODE_OTLP_PROTOCOL], ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); return [ diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 50f5e5f0ac84..84d08cd9b6b8 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -21,12 +21,17 @@ describe("electron development launcher", () => { VITE_DEV_SERVER_URL: "http://127.0.0.1:8526", T3CODE_PORT: "16566", T3CODE_HOME: "/tmp/t3", + T3CODE_OTLP_PROTOCOL: "http/protobuf", }); assert.include( environmentScript, "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi", ); + assert.include( + environmentScript, + "if [ -z \"${T3CODE_OTLP_PROTOCOL:-}\" ]; then export T3CODE_OTLP_PROTOCOL='http/protobuf'; fi", + ); assert.notInclude(environmentScript, "\nexport VITE_DEV_SERVER_URL="); }); diff --git a/apps/desktop/scripts/main-process-bundle.test.mjs b/apps/desktop/scripts/main-process-bundle.test.mjs new file mode 100644 index 000000000000..28d8e37d7a9e --- /dev/null +++ b/apps/desktop/scripts/main-process-bundle.test.mjs @@ -0,0 +1,110 @@ +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeVM from "node:vm"; +import { build } from "vite-plus/pack"; +import { assert, it } from "vite-plus/test"; + +import desktopConfig from "../vite.config.ts"; + +it("keeps lazy Linux imports and worker bundles from executing desktop startup twice", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-bundle-")); + try { + const workerEntries = [ + "src/electron/WindowsForegroundFocusWorker.ts", + "src/snapShot/GlobalShiftShortcutWorker.ts", + "src/snapShot/RegionSnapShotWorker.ts", + "src/snapShot/SnapShotAccessibilityWorker.ts", + ]; + await Promise.all([ + NodeFSP.mkdir(NodePath.join(directory, "src/electron"), { recursive: true }), + NodeFSP.mkdir(NodePath.join(directory, "src/snapShot"), { recursive: true }), + ]); + await Promise.all([ + NodeFSP.writeFile( + NodePath.join(directory, "src/main.ts"), + `import { shared } from "./shared.ts"; +process.emit("startup", shared.value); +void import("./linux.ts").then(({ result }) => process.emit("ready", result));`, + ), + NodeFSP.writeFile( + NodePath.join(directory, "src/shared.ts"), + "export const shared = { value: 42 };", + ), + NodeFSP.writeFile( + NodePath.join(directory, "src/linux.ts"), + 'import { shared } from "./shared.ts"; export const result = shared.value + 1;', + ), + ...workerEntries.map((entry) => + NodeFSP.writeFile( + NodePath.join(directory, entry), + 'import { shared } from "../shared.ts"; process.emit("worker", shared.value);', + ), + ), + ]); + assert.ok(Array.isArray(desktopConfig.pack)); + const fixtureEntries = new Set(["src/main.ts", ...workerEntries]); + for (const packConfig of desktopConfig.pack) { + if (!Array.isArray(packConfig.entry)) continue; + if (!packConfig.entry.some((entry) => fixtureEntries.has(entry))) continue; + await build({ + ...packConfig, + config: false, + cwd: directory, + tsconfig: false, + sourcemap: false, + onSuccess: undefined, + logLevel: "silent", + }); + } + + const outputDirectory = NodePath.join(directory, "dist-electron"); + const filenames = (await NodeFSP.readdir(outputDirectory, { recursive: true })).filter( + (filename) => filename.endsWith(".cjs"), + ); + const sources = new Map( + await Promise.all( + filenames.map(async (filename) => { + const path = NodePath.join(outputDirectory, filename); + return [path, await NodeFSP.readFile(path, "utf8")]; + }), + ), + ); + const modules = new Map(); + const startups = []; + const workers = []; + const ready = Promise.withResolvers(); + const load = (filename, cacheModule = true) => { + const cached = modules.get(filename); + if (cached) return cached.exports; + const module = { exports: {} }; + if (cacheModule) modules.set(filename, module); + const source = sources.get(filename); + assert.ok(source, `Missing bundle: ${filename}`); + NodeVM.runInNewContext(source, { + exports: module.exports, + module, + require: (specifier) => load(NodePath.resolve(NodePath.dirname(filename), specifier)), + process: { + emit: (event, value) => { + if (event === "startup") startups.push(value); + if (event === "worker") workers.push(value); + if (event === "ready") ready.resolve(value); + }, + }, + }); + return module.exports; + }; + + load(NodePath.join(outputDirectory, "main.cjs"), false); + assert.equal(await ready.promise, 43); + assert.deepEqual(startups, [42]); + for (const entry of workerEntries) { + load(NodePath.join(outputDirectory, entry.replace(/^src\//, "").replace(/\.ts$/, ".cjs"))); + } + assert.deepEqual(workers, [42, 42, 42, 42]); + assert.deepEqual(startups, [42]); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index d157a4c6ba44..924b8ce6e72b 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -1,3 +1,4 @@ +import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Option from "effect/Option"; @@ -48,6 +49,10 @@ export const DesktopConfig = Config.all({ otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), + otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(Config.option), + otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( + Config.withDefault("http/json"), + ), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 1ebd5dae56c2..0e5fbecd0224 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -52,6 +52,8 @@ describe("DesktopEnvironment", () => { T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", + T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", + T3CODE_OTLP_PROTOCOL: "http/protobuf", }, ); @@ -85,6 +87,14 @@ describe("DesktopEnvironment", () => { assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); assert.equal(environment.otlpExportIntervalMs, 2500); + assert.deepEqual( + environment.otlpHeaders, + Option.some({ + authorization: "Basic abc==", + "x-tenant": "t3", + }), + ); + assert.equal(environment.otlpProtocol, "http/protobuf"); }), ); @@ -102,6 +112,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); + assert.equal(environment.otlpProtocol, "http/json"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index e604cb767f3f..e7a489d5e89d 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -16,6 +16,7 @@ import * as DesktopConfig from "./DesktopConfig.ts"; import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; +import type { OtlpProtocol } from "@t3tools/shared/observability"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; @@ -72,6 +73,8 @@ export class DesktopEnvironment extends Context.Service< readonly commitHashOverride: Option.Option; readonly otlpTracesUrl: Option.Option; readonly otlpExportIntervalMs: number; + readonly otlpHeaders: Option.Option>; + readonly otlpProtocol: OtlpProtocol; readonly branding: DesktopAppBranding; readonly displayName: string; readonly appUserModelId: string; @@ -225,6 +228,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( commitHashOverride: config.commitHashOverride, otlpTracesUrl: config.otlpTracesUrl, otlpExportIntervalMs: config.otlpExportIntervalMs, + otlpHeaders: config.otlpHeaders, + otlpProtocol: config.otlpProtocol, branding, displayName, appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index d2ff0b4e2ad5..19ed351dffb7 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,5 +1,9 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; -import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import { + makeLocalFileTracer, + makeTraceSink, + otlpSerializationLayer, +} from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -17,7 +21,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Tracer from "effect/Tracer"; -import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import { OtlpExporter, OtlpTracer } from "effect/unstable/observability"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -584,6 +588,7 @@ const tracerLayer = Layer.unwrap( : yield* OtlpTracer.make({ url: otlpTracesUrl.value, exportInterval: `${environment.otlpExportIntervalMs} millis`, + headers: Option.getOrUndefined(environment.otlpHeaders), resource: { serviceName: "desktop", attributes: { @@ -591,7 +596,7 @@ const tracerLayer = Layer.unwrap( "service.mode": environment.isDevelopment ? "development" : "packaged", }, }, - }); + }).pipe(Effect.provide(otlpSerializationLayer(environment.otlpProtocol))); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, @@ -603,7 +608,7 @@ const tracerLayer = Layer.unwrap( return Layer.succeed(Tracer.Tracer, tracer); }), -).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(OtlpSerialization.layerJson)); +).pipe(Layer.provide(OtlpExporter.layerFlusher)); export const layer = Layer.mergeAll( backendOutputLogFactoryLayer, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 649e0757fd45..4978898f7e4e 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -399,10 +399,10 @@ describe("DesktopBackendConfiguration", () => { observedProbeRoots.push(root); return { ok: true, resolvedPath }; }, - // The staged runtime carries its own Node, so the preflight must not - // go looking for one in the distro. + // The staged runtime carries its own Node and node-pty, so it must + // not require the mounted server tree's native dependency check. ensureNodePty: () => { - throw new Error("the staged runtime must not probe for Node"); + throw new Error("the staged runtime must not probe for node-pty"); }, }), }, @@ -890,10 +890,14 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousOpenAiKey = process.env.OPENAI_API_KEY; const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; + const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; + const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; try { process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; process.env.OPENAI_API_KEY = "openai-key"; process.env.ANTHROPIC_API_KEY = "anthropic-key"; + process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"'; + process.env.T3CODE_OTLP_PROTOCOL = "http/protobuf"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -913,13 +917,14 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); assert.equal(config.env.OPENAI_API_KEY, "openai-key"); assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); + assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); // The existing WSLENV is preserved byte-for-byte (note the empty // "::" segment survives — WSL ignores it, so we don't normalize // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is // already declared, so it isn't forwarded twice. assert.equal( config.env.WSLENV, - "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY", + "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", ); }).pipe( Effect.provide( @@ -942,6 +947,8 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OPENAI_API_KEY", previousOpenAiKey); restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); + restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); + restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 43f9a6295ff0..82b1e9fc3727 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -87,11 +87,16 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_TAILSCALE_SERVE_PORT", ] as const; -// Sensitive env vars that the WSL backend needs but Windows process.env won't -// forward across the wsl.exe boundary without WSLENV. The dev-server URL is -// handled separately via a `--dev-url` CLI flag because WSLENV translation of +// Env vars that the WSL backend needs but Windows process.env won't forward +// across the wsl.exe boundary without WSLENV. The dev-server URL is handled +// separately via a `--dev-url` CLI flag because WSLENV translation of // URL-shaped values (colons / slashes) is unreliable. -const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const; +const WSL_FORWARDED_ENV_NAMES = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "T3CODE_OTLP_HEADERS", + "T3CODE_OTLP_PROTOCOL", +] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; @@ -382,8 +387,8 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - // The staged runtime is self-contained, so the only question is whether - // it runs here; there is no Node to find or node-pty to load. + // The staged runtime supplies its own Node and node-pty. Provider PATH + // discovery must not require either dependency for runtime readiness. const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 8cd08a41a90d..238680366071 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -6,7 +6,7 @@ import type { DesktopSnapShotEvent, } from "@t3tools/contracts"; import { exposeClerkBridge } from "@clerk/electron/preload"; -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webFrame } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -32,6 +32,19 @@ exposeClerkBridge({ passkeys: true }); // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. const clientPlatform = process.platform; +if (clientPlatform === "darwin") { + // Native window buttons do not scale with Chromium zoom. Keep their reserved + // space in native points, including when a zoomed page is reloaded. + const syncWindowControlInset = () => { + document.documentElement.style.setProperty( + "--desktop-window-controls-inset", + `${90 / webFrame.getZoomFactor()}px`, + ); + }; + window.addEventListener("DOMContentLoaded", syncWindowControlInset, { once: true }); + window.addEventListener("resize", syncWindowControlInset); +} + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 51584e3c796d..c35b52e8343d 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -711,6 +711,7 @@ export const make = Effect.gen(function* () { const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( info.releaseNotes, info.version, + state.channel, ); yield* setState( reduceDesktopUpdateStateOnUpdateAvailable( diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 3ba2444dc185..57d185c0975e 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -21,6 +21,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { "**Full Changelog**: https://github.com/pingdotgg/t3code/compare/old...new", ].join("\n"), "0.0.36-nightly.20260828.1213", + "nightly", ); expect(result).toEqual({ @@ -50,6 +51,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { "

New Contributors

  • @human made their first contribution
" + "

Full Changelog

", "1.2.3", + "latest", ); expect(result).toEqual({ @@ -69,6 +71,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }, ], "1.2.4", + "latest", ); expect(result.releaseNotes).toEqual([ @@ -85,6 +88,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { { version: "1.2.1", note: "- Older release" }, ], "1.2.3", + "latest", ); expect(result).toEqual({ @@ -96,6 +100,42 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); + it("drops releases from other trains before grouping on nightly", () => { + // electron-updater's full changelog is "every version above the running + // one", and preview sorts above nightly, so the preview cuts come first. + const releaseNotes = [ + { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, + { version: "0.0.41-preview.20260913.1669", note: "- Maintainer test build" }, + { version: "0.0.41-nightly.20260914.1707", note: "- Nightly change 2" }, + { version: "0.0.41-nightly.20260914.1700", note: "- Nightly change 1" }, + ]; + + const result = normalizeDesktopUpdateReleaseNotes( + releaseNotes, + "0.0.41-nightly.20260914.1707", + "nightly", + ); + + expect(result.releaseNotes.map(({ version }) => version)).toEqual([ + "0.0.41-nightly.20260914.1707", + "0.0.41-nightly.20260914.1700", + ]); + expect(result.omittedReleaseCount).toBe(0); + }); + + it("keeps only stable releases on the latest channel", () => { + const result = normalizeDesktopUpdateReleaseNotes( + [ + { version: "0.0.42", note: "- Stable change" }, + { version: "0.0.42-nightly.20260915.1710", note: "- Nightly change" }, + ], + "0.0.42", + "latest", + ); + + expect(result.releaseNotes.map(({ version }) => version)).toEqual(["0.0.42"]); + }); + it("counts valid groups before applying the six-release limit", () => { const releaseNotes = [ { version: "1.3.9", note: "- Change 9" }, @@ -108,7 +148,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { { version: "1.3.2", note: "- Change 2" }, ]; - const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9"); + const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9", "latest"); expect(result.releaseNotes.map(({ version }) => version)).toEqual([ "1.3.9", @@ -122,7 +162,11 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("decodes valid HTML entities", () => { - const result = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + const result = normalizeDesktopUpdateReleaseNotes( + "- Fix & polish 😀", + "1.0.0", + "latest", + ); expect(result).toEqual({ releaseNotes: [{ version: "1.0.0", items: ["Fix & polish 😀"], totalItems: 1 }], omittedReleaseCount: 0, @@ -140,6 +184,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { null, ], "1.2.3", + "latest", ); expect(result).toEqual({ @@ -149,14 +194,18 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("returns an empty result for an invalid payload", () => { - expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0")).toEqual({ + expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0", "latest")).toEqual({ releaseNotes: [], omittedReleaseCount: 0, }); }); it("does not throw on out-of-range numeric entities and keeps the literal", () => { - const result = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + const result = normalizeDesktopUpdateReleaseNotes( + "- Broken entity �", + "1.0.0", + "latest", + ); expect(result).toEqual({ releaseNotes: [{ version: "1.0.0", items: ["Broken entity �"], totalItems: 1 }], omittedReleaseCount: 0, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 3b2f32e646a1..3cab5f15e451 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -1,4 +1,6 @@ -import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; +import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts"; + +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; interface ElectronReleaseNoteInfo { readonly version: string; @@ -122,16 +124,27 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } +/** + * Turns electron-updater's release notes into the groups the popover shows. + * With `fullChangelog` on (nightly), electron-updater collects every GitHub + * release whose version is semver-greater than the running one, whatever + * train it belongs to; a maintainers' `-preview.` cut sorts above every + * `-nightly.` of the same base version and would lead the list. Only + * releases on the channel being followed are kept, the same test the + * updater applies to the offered version itself. + */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, + channel: DesktopUpdateChannel, ): NormalizedDesktopUpdateReleaseNotes { - const rawNotes = + const rawNotes = ( typeof releaseNotes === "string" ? [{ version: fallbackVersion, note: releaseNotes }] : Array.isArray(releaseNotes) ? releaseNotes.filter(isElectronReleaseNoteInfo) - : []; + : [] + ).filter((entry) => resolveDefaultDesktopUpdateChannel(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { const { items, totalItems } = extractReleaseNoteItems(entry.note); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c66fdce2f3f6..3dcb3fc2d5c1 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -77,6 +77,7 @@ function makeFakeBrowserWindow() { isDestroyed: vi.fn(() => false), getURL: vi.fn(() => "t3code-dev://app/"), getZoomLevel: vi.fn(() => zoomLevel), + getZoomFactor: vi.fn(() => 1.2 ** zoomLevel), setZoomLevel: vi.fn((level: number) => { zoomLevel = level; }), @@ -118,6 +119,7 @@ function makeFakeBrowserWindow() { setOpacity: vi.fn(), setTitle: vi.fn(), setTitleBarOverlay: vi.fn(), + setWindowButtonPosition: vi.fn(), show: vi.fn(), webContents, }; @@ -136,6 +138,7 @@ function makeFakeBrowserWindow() { reload: webContents.reload, send: webContents.send, setZoomLevel: webContents.setZoomLevel, + setWindowButtonPosition: window.setWindowButtonPosition, setBackgroundThrottling: webContents.setBackgroundThrottling, setAutoHideCursor: window.setAutoHideCursor, setFullScreen: window.setFullScreen, @@ -743,6 +746,39 @@ describe("DesktopWindow", () => { }), ); + it.effect("keeps macOS window buttons centered when zooming and leaving fullscreen", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + for (const direction of ["in", "in", "out", "reset", "out"] as const) { + yield* desktopWindow.zoomMain(direction); + const position = fakeWindow.setWindowButtonPosition.mock.lastCall?.[0]; + assert.isDefined(position); + // The 14-point native buttons should share the zoomed 52px header's center. + const headerCenter = 26 * fakeWindow.window.webContents.getZoomFactor(); + assert.isAtMost(Math.abs(position.y + 7 - headerCenter), 0.5); + assert.equal(position.x, 16); + } + + fakeWindow.isFullScreen.mockReturnValue(true); + fakeWindow.setWindowButtonPosition.mockClear(); + yield* desktopWindow.zoomMain("reset"); + assert.equal(fakeWindow.setWindowButtonPosition.mock.calls.length, 0); + + fakeWindow.isFullScreen.mockReturnValue(false); + fakeWindow.windowListeners.get("leave-full-screen")?.(); + assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 16, y: 19 }]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index e19a75962126..b3964cb929c7 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -31,6 +31,22 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import { makeQuitShortcutHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; +// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS +// buttons are 14 points tall and do not scale with the renderer's zoom. +const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52; +const MACOS_WINDOW_BUTTON_RADIUS = 7; + +function syncMacosWindowButtons(window: Electron.BrowserWindow): void { + if (window.isDestroyed() || window.isFullScreen()) return; + window.setWindowButtonPosition({ + x: 16, + y: Math.round( + (MACOS_WORKSPACE_TOPBAR_HEIGHT * window.webContents.getZoomFactor()) / 2 - + MACOS_WINDOW_BUTTON_RADIUS, + ), + }); +} + const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; @@ -241,7 +257,10 @@ function getWindowTitleBarOptions( if (platform === "darwin") { return { titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, + trafficLightPosition: { + x: 16, + y: MACOS_WORKSPACE_TOPBAR_HEIGHT / 2 - MACOS_WINDOW_BUTTON_RADIUS, + }, }; } @@ -660,6 +679,7 @@ export const make = Effect.gen(function* () { window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); }); window.on("leave-full-screen", () => { + syncMacosWindowButtons(window); window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); }); } @@ -720,6 +740,7 @@ export const make = Effect.gen(function* () { clearDevelopmentLoadRetry(); developmentLoadRetryIndex = 0; window.setTitle(environment.displayName); + if (environment.platform === "darwin") syncMacosWindowButtons(window); }); window.webContents.on( "did-fail-load", @@ -990,6 +1011,7 @@ export const make = Effect.gen(function* () { webContents.setZoomLevel( direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); + if (environment.platform === "darwin") syncMacosWindowButtons(window.value); // Chromium pushes the new level down to embedded guests, which would zoom // the previewed page along with the app UI. The preview browser keeps its // own zoom, so put each guest back where the preview left it. diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 30916e194992..7af2902ca503 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -15,6 +15,7 @@ import { buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, + buildWslRuntimeProbeScript, DesktopWslDistroListError, formatMissingToolsReason, parseNodePath, @@ -473,6 +474,75 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed }; }; + const probeFixture = (fixture: ReturnType) => + runShell( + [ + `export HOME=${sh(`${fixture.work}/home`)}`, + 'export NVM_DIR="$HOME/.nvm" FNM_DIR="$HOME/.fnm" VOLTA_HOME="$HOME/.volta"', + // Isolate login profiles and hide the host's Node/version managers. + // The resolver must discover the fixture's installation itself. + "bash() { (", + " command() {", + ' case "$*" in', + ' "-v node"|"-v mise"|"-v fnm"|"-v nodenv") return 1 ;;', + ' *) builtin command "$@" ;;', + " esac", + " }", + ' eval "$2"', + "); }", + buildWslRuntimeProbeScript(fixture.runtimeRoot), + ].join("\n"), + ); + + it("discovers version-managed Node for providers with a standalone runtime", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const nodeBin = `${fixture.work}/home/.nvm/versions/node/v24.15.0/bin`; + const setup = runShell( + [ + "set -eu", + `mkdir -p ${sh(nodeBin)}`, + `printf '%s' ${sh('#!/bin/sh\nprintf "linux-node-provider\\n"\n')} > ${sh(`${nodeBin}/node`)}`, + `chmod +x ${sh(`${nodeBin}/node`)}`, + ].join("\n"), + ); + expect(setup.status, setup.stderr).toBe(0); + + const probe = probeFixture(fixture); + + expect(probe.status, probe.stderr).toBe(0); + const resolvedPath = parseResolvedPath(probe.stdout); + expect(resolvedPath?.split(":")).toContain(nodeBin); + const provider = runShell(`export PATH=${sh(resolvedPath ?? "")}\nnode provider.js`); + expect(provider.status, provider.stderr).toBe(0); + expect(provider.stdout).toBe("linux-node-provider\n"); + }); + + it("keeps standalone runtime readiness independent of Node availability", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + + const probe = probeFixture(fixture); + + expect(probe.status, probe.stderr).toBe(0); + expect(parseResolvedPath(probe.stdout)).not.toBeNull(); + }); + + it("keeps the inherited PATH when bash is unavailable", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const probe = runShell( + [ + "bash() { return 127; }", + 'export PATH="/fixture/bin:/usr/bin:/bin"', + buildWslRuntimeProbeScript(fixture.runtimeRoot), + ].join("\n"), + ); + + expect(probe.status, probe.stderr).toBe(0); + expect(parseResolvedPath(probe.stdout)).toBe("/fixture/bin:/usr/bin:/bin"); + }); + it("reuses a warm cache without touching the archive", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index d6fcb9dc5a8c..a6cba3a67689 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -124,8 +124,8 @@ export class DesktopWslEnvironment extends Context.Service< // Marks a staged runtime as unusable so the next launch reinstalls it. readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; // Proves a staged self-contained runtime can run (`/t3 --version`) - // and captures the user's login-shell PATH for the launch. Needs no Node - // in the distro; the mounted server tree still goes through ensureNodePty. + // and resolves the user's PATH, including version-managed Node for provider + // CLIs. Node is optional; the mounted tree still requires ensureNodePty. readonly probeRuntime: ( distro: string | null, linuxAppRoot: string, @@ -547,13 +547,12 @@ require("node-pty"); NODE`; // Readiness proof for a staged self-contained runtime: the executable runs and -// reports its version, and the login shell's PATH is captured for the launch. -// This runs under plain `sh` (no Node resolver preamble, since the runtime -// needs no Node), so the login shell is entered explicitly for the PATH -// capture; a distro without bash falls back to the PATH sh was started with. -const RUNTIME_PROBE_SCRIPT = (linuxAppRoot: string) => +// reports its version. Provider CLIs may still need version-managed Node, so +// resolve it before capturing PATH without requiring it for runtime readiness. +// A distro without bash falls back to the PATH sh was started with. +export const buildWslRuntimeProbeScript = (linuxAppRoot: string) => [ - `bash -lc ${shellQuote(RESOLVED_PATH_LINE)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, + `bash -lc ${shellQuote(`${buildWslNodeEnvPreamble()}${RESOLVED_PATH_LINE}`)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`, ].join("\n"); @@ -686,9 +685,14 @@ const probeWslRuntimeImpl = ( linuxAppRoot: string, ): Effect.Effect => Effect.gen(function* () { - const probe = yield* runWslShell(distro, RUNTIME_PROBE_SCRIPT(linuxAppRoot), PROBE_TIMEOUT, { - resolveNode: false, - }); + const probe = yield* runWslShell( + distro, + buildWslRuntimeProbeScript(linuxAppRoot), + PROBE_TIMEOUT, + { + resolveNode: false, + }, + ); const transportFailureReason = formatWslShellTransportFailureReason( probe.transportFailure, "the staged runtime", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f3ec31ed34d9..c451a89b5767 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -48,6 +48,23 @@ export default defineConfig({ }, }, pack: [ + { + format: "cjs", + outDir: "dist-electron", + dts: false, + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), + define: publicConfigDefine, + outputOptions: { codeSplitting: false }, + entry: ["src/main.ts"], + clean: true, + deps: { + alwaysBundle: (id) => !id.startsWith("node:") && !isMainProcessExternal(id), + neverBundle: isMainProcessExternal, + onlyBundle: false, + }, + ...(shouldLaunchElectronAfterPack ? { onSuccess: "node scripts/dev-electron.mjs" } : {}), + }, { format: "cjs", outDir: "dist-electron", @@ -56,19 +73,17 @@ export default defineConfig({ outExtensions: () => ({ js: ".cjs" }), define: publicConfigDefine, entry: [ - "src/main.ts", "src/electron/WindowsForegroundFocusWorker.ts", "src/snapShot/GlobalShiftShortcutWorker.ts", "src/snapShot/RegionSnapShotWorker.ts", "src/snapShot/SnapShotAccessibilityWorker.ts", ], - clean: true, + clean: false, deps: { alwaysBundle: (id) => !id.startsWith("node:") && !isMainProcessExternal(id), neverBundle: isMainProcessExternal, onlyBundle: false, }, - ...(shouldLaunchElectronAfterPack ? { onSuccess: "node scripts/dev-electron.mjs" } : {}), }, { format: "cjs", diff --git a/apps/mobile/README.md b/apps/mobile/README.md index a9a8177c4ece..20f98c6c9e34 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -22,7 +22,22 @@ repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See ## Development -Start Metro for the dev client: +For simulator/emulator development, select and boot a device, then ensure its native client matches +this checkout before starting Metro: + +```bash +node ../../scripts/mobile-native-client.ts ensure ios +# Or: node ../../scripts/mobile-native-client.ts ensure android +vp run dev:client +``` + +The helper compares a local Expo fingerprint and the installed binary with its last successful +build record. It builds and installs missing, stale, or unverified clients and reuses matching ones. +Use `check` instead of `ensure` for a read-only decision: exit 0 means compatible, 2 means a build is +needed, and 1 means an operational error. Run it on the simulator host; no EAS login is required. +An externally installed client is unverified until the helper builds it once. + +Start Metro for an already verified dev client: ```bash vp run dev:client diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 5daaa64a7985..ddc022508d05 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -214,7 +214,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.1.1", + version: "1.2.0", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 2f686e3a1fa9..7a401fd7289d 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -233,6 +233,31 @@ } } +/* ─── Clerk native profile ──────────────────────────────────────────── */ +/* Fixed palette for custom pages inside Clerk's native user profile. Mirrors + clerk-theme.json, which themes the SDK's own screens, so ours match them. + Kept out of the runtime palette above on purpose: custom themes must not + restyle Clerk's chrome. Keep in sync with clerk-theme.json. */ +@layer theme { + :root { + @variant light { + --color-clerk-page: #f2f2f7; + --color-clerk-foreground: #262626; + --color-clerk-foreground-muted: #737373; + --color-clerk-border: rgba(229, 229, 234, 0.06); + --color-clerk-danger: #dc2626; + } + + @variant dark { + --color-clerk-page: #0e0e0e; + --color-clerk-foreground: #f5f5f5; + --color-clerk-foreground-muted: #a3a3a3; + --color-clerk-border: rgba(42, 42, 42, 0.06); + --color-clerk-danger: #fca5a5; + } + } +} + /* ─── Typography ────────────────────────────────────────────────────── */ @theme { /* Keep these native family names aligned with app.config.ts. */ diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt new file mode 100644 index 000000000000..feca1133d9a3 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt @@ -0,0 +1,201 @@ +package expo.modules.t3agentnotifications + +import android.content.Context +import android.graphics.Typeface +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat + +internal data class ActivityRow(val status: String, val title: String, val project: String) + +/** + * One entry per relay phase. The status label matches the relay's row wording and the + * tint matches the web sidebar pills and the iOS Live Activity so a thread reads the + * same on every surface. The icon is always the T3 mark; the chip verb carries the state. + */ +internal enum class ActivityPhase( + val status: String, + val heading: String, + val chip: String, + val action: String, + val color: Int +) { + STARTING("Connecting", "Starting", "Working", "Open", R.color.agent_activity_working), + RUNNING("Working", "Working", "Working", "Open", R.color.agent_activity_working), + APPROVAL( + "Approval", + "Approval needed", + "Approve", + "Approve", + R.color.agent_activity_attention + ), + INPUT( + "Input", + "Question for you", + "Answer", + "Answer", + R.color.agent_activity_input + ), + STALE( + "Waiting", + "Waiting for an update", + "Waiting", + "Open", + R.color.agent_activity_waiting + ), + COMPLETED( + "Done", + "Finished", + "Done", + "Open", + R.color.agent_activity_done + ), + FAILED( + "Failed", + "Failed", + "Failed", + "Open", + R.color.agent_activity_failed + ); + + val needsUser get() = this == APPROVAL || this == INPUT + val finished get() = this == COMPLETED || this == FAILED + + companion object { + fun forStatus(status: String) = entries.firstOrNull { it.status == status } + } +} + +/** The relay orders rows and their deep link together; never reorder them in the client. */ +internal fun activityRows(data: Map) = (0..4).mapNotNull { + val parts = data["activity_line_$it"]?.split('\t', limit = 3) ?: return@mapNotNull null + if (parts.size != 3 || parts[1].isBlank()) { + null + } else { + ActivityRow(parts[0].take(40), parts[1].take(120), parts[2].take(120)) + } +} + +internal fun activityPhase(data: Map, rows: List): ActivityPhase? = + when (data["activity_phase"]?.takeIf { it.isNotBlank() }) { + "starting" -> ActivityPhase.STARTING + "running" -> ActivityPhase.RUNNING + "waiting_for_approval" -> ActivityPhase.APPROVAL + "waiting_for_input" -> ActivityPhase.INPUT + "stale" -> ActivityPhase.STALE + "completed" -> ActivityPhase.COMPLETED + "failed" -> ActivityPhase.FAILED + else -> rows.firstOrNull()?.let { ActivityPhase.forStatus(it.status) } + } + +/** + * Header carries the state, the title says what needs you (or which thread, when + * there is only one), and the body lists every thread with its status in front. + * System UI renders all of it, so the same builder serves the shade, the lock + * screen and the status bar chip. + */ +internal class ActivityPresentation(data: Map, private val active: Boolean) { + private val rows = activityRows(data) + private val hero = rows.firstOrNull() + val phase = activityPhase(data, rows) + private val activeCount = data["activity_active_count"]?.toIntOrNull()?.coerceAtLeast(0) + ?: rows.count { ActivityPhase.forStatus(it.status)?.finished != true } + private val attentionCount = data["activity_attention_count"]?.toIntOrNull()?.coerceAtLeast(0) + ?: rows.count { ActivityPhase.forStatus(it.status)?.needsUser == true } + private val failedCount = rows.count { it.status == ActivityPhase.FAILED.status } + val threadCount = + activeCount + rows.count { ActivityPhase.forStatus(it.status)?.finished == true } + private val singleProject = rows.map { it.project }.distinct().size == 1 + private val legacyBody = (0..4).mapNotNull { data["activity_line_$it"]?.take(300) } + .takeIf { it.isNotEmpty() }?.joinToString("\n") + ?: data["activity_body"].orEmpty().take(240) + + val summary = when { + hero == null -> data["activity_title"]?.takeIf { it.isNotBlank() }?.take(120) + ?: "Agent activity" + rows.size == 1 -> hero.title + attentionCount == 1 -> "1 needs you" + attentionCount > 1 -> "$attentionCount need you" + activeCount > 0 && failedCount > 0 -> "$failedCount failed" + activeCount > 0 -> "$activeCount working" + failedCount > 0 -> "Finished, $failedCount failed" + else -> "All finished" + } + + val chip = when { + !active -> null + phase == null -> data["activity_chip"]?.takeIf { it.isNotBlank() }?.take(7) ?: "Active" + phase == ActivityPhase.RUNNING && activeCount > 1 -> + "${if (activeCount > 9) "9+" else activeCount} live" + else -> phase.chip + } + + val action = if (active) phase?.action ?: "Open" else null + + fun applyTo(builder: NotificationCompat.Builder, context: Context) { + val tint = phase?.let { ContextCompat.getColor(context, it.color) } + builder.setSmallIcon(R.drawable.agent_activity_mark) + if (tint != null) builder.setColor(tint) + // Tint the summary only when it names an outcome or a request; a plain + // "3 working" stays neutral so the accent keeps meaning something. + val tintedSummary = tint != null && rows.size > 1 && phase != ActivityPhase.RUNNING && + phase != ActivityPhase.STARTING + builder.setContentTitle(if (tintedSummary) tinted(summary, tint!!) else summary) + if (rows.size > 1) { + builder.setSubText( + listOfNotNull( + hero!!.project.takeIf { singleProject && it.isNotBlank() }, + if (activeCount > 0) "$activeCount active" else "$threadCount threads" + ).joinToString(" · ") + ) + } + val body = body(context) + // The collapsed card gets the priority row; the expanded card gets them all. + val lineBreak = body.indexOf('\n') + val firstLine = if (lineBreak >= 0) body.subSequence(0, lineBreak) else body + builder.setContentText(firstLine).setStyle(NotificationCompat.BigTextStyle().bigText(body)) + // Thread update timestamps can change while an approval remains pending. + // Leave the timer hidden until the payload has a stable phase-entry timestamp. + builder.setShowWhen(false).setUsesChronometer(false) + } + + private fun body(context: Context): CharSequence = when { + hero == null -> legacyBody + // The title already names the thread; the body only needs its status and project. + rows.size == 1 -> statusLine(context, hero.copy(title = hero.project), "") + else -> SpannableStringBuilder().apply { + rows.forEachIndexed { index, row -> + if (index > 0) append("\n") + append(statusLine(context, row, row.project.takeUnless { singleProject }.orEmpty())) + } + } + } + + private fun statusLine(context: Context, row: ActivityRow, trailing: String): CharSequence = + SpannableStringBuilder().apply { + val status = ActivityPhase.forStatus(row.status) + val color = ContextCompat.getColor(context, status?.color ?: R.color.agent_activity_waiting) + append(tinted(row.status, color, bold = true)) + append(" ").append(row.title) + // Promoted cards drop text color, so the separator has to do the work of the dimming. + if (trailing.isNotBlank()) { + val start = length + append(" · ").append(trailing) + setSpan( + ForegroundColorSpan(ContextCompat.getColor(context, R.color.agent_activity_waiting)), + start, + length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + + private fun tinted(text: String, color: Int, bold: Boolean = false) = + SpannableStringBuilder(text).apply { + setSpan(ForegroundColorSpan(color), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + if (bold) setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } +} diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index d1b81661151c..35086bbd75f9 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -11,8 +11,6 @@ import android.content.Intent import android.content.SharedPreferences import android.net.Uri import android.os.Build -import android.text.TextPaint -import android.text.TextUtils import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.Lifecycle @@ -197,34 +195,32 @@ object AgentNotifications { active: Boolean, remainingMs: Long ) { - val body = data["activity_body"].orEmpty().take(240) val dismissIntent = PendingIntent.getBroadcast( context, ACTIVITY_ID, Intent(context, AgentActivityDismissReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val lines = (0..4).mapNotNull { - data["activity_line_$it"]?.let { line -> activityLine(context, line) } - } - // BigTextStyle remains eligible for Android Live Update promotion. - val style = NotificationCompat.BigTextStyle().bigText( - if (lines.isEmpty()) body else lines.joinToString("\n") - ) - val notification = base(context, ACTIVITY_CHANNEL) - .setContentTitle(data["activity_title"].orEmpty().take(120)) - .setContentText(body) - .setStyle(style) + val presentation = ActivityPresentation(data, active) + val openThread = contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID) + val builder = base(context, ACTIVITY_CHANNEL) .setOngoing(active).setOnlyAlertOnce(true).setSilent(true) .setTimeoutAfter(remainingMs) // Live Updates must remain uncolorized to qualify for promotion. .setColorized(false) .setRequestPromotedOngoing(active) - .setContentIntent(contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID)) + .setShortCriticalText(presentation.chip) + .setContentIntent(openThread) .setDeleteIntent(dismissIntent) - .addAction(0, "Dismiss", dismissIntent) - .build() - manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, notification) + presentation.applyTo(builder, context) + // A finished card is no longer ongoing, so it swipes away and a tap opens + // the thread; buttons would only repeat that. + val action = presentation.action + if (action != null) { + if (openThread != null) builder.addAction(0, action, openThread) + builder.addAction(0, "Dismiss", dismissIntent) + } + manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, builder.build()) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { // Notification timeouts were added in API 26. One inexact alarm also // expires cards on Android 7, including when the app process has exited. @@ -253,32 +249,6 @@ object AgentNotifications { } } - private fun activityLine(context: Context, value: String): String { - val parts = value.split('\t', limit = 3) - if (parts.size != 3) return value.take(300) - val metrics = context.resources.displayMetrics - val paint = TextPaint().apply { textSize = 14 * metrics.scaledDensity } - val prefix = "${parts[0]}: " - val separator = " · " - // Reserve the system notification's icon and margins. Fit the two titles - // independently so large fonts/long names never hide the project or status. - // The shade uses a narrow column even when a headless service sees a - // foldable's wider display metrics. Keep rows inside that column too. - val width = (metrics.widthPixels - 152 * metrics.density) - .coerceIn(120 * metrics.density, 280 * metrics.density) - val available = (width - paint.measureText(prefix + separator)).coerceAtLeast(0f) - val projectWidth = paint.measureText(parts[2]).coerceAtMost(available * 0.4f) - val titleWidth = paint.measureText(parts[1]).coerceAtMost(available - projectWidth) - val title = TextUtils.ellipsize(parts[1], paint, titleWidth, TextUtils.TruncateAt.END) - val project = TextUtils.ellipsize( - parts[2], - paint, - available - titleWidth, - TextUtils.TruncateAt.END - ) - return "$prefix$title$separator$project" - } - private fun manager(context: Context) = context.getSystemService(NotificationManager::class.java) private fun channels(context: Context) { diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt index d394db56115f..246db274a195 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt @@ -1,5 +1,9 @@ package expo.modules.t3agentnotifications +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Build +import android.provider.Settings import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -21,5 +25,23 @@ class T3AgentNotificationsModule : Module() { Function("clear") { appContext.reactContext?.let { AgentNotifications.clear(it) } } + + Function("openLiveUpdateSettings") { + val context = appContext.reactContext + if (context == null || Build.VERSION.SDK_INT < 36) { + false + } else { + try { + context.startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_PROMOTION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + true + } catch (_: ActivityNotFoundException) { + false + } + } + } } } diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/res/drawable/agent_activity_mark.xml b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/drawable/agent_activity_mark.xml new file mode 100644 index 000000000000..7aa01bac42c1 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/drawable/agent_activity_mark.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values-night/agent_activity_colors.xml b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values-night/agent_activity_colors.xml new file mode 100644 index 000000000000..e08b159d9f04 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values-night/agent_activity_colors.xml @@ -0,0 +1,9 @@ + + + #7DD3FC + #FCD34D + #A5B4FC + #94A3B8 + #6EE7B7 + #FCA5A5 + diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values/agent_activity_colors.xml b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values/agent_activity_colors.xml new file mode 100644 index 000000000000..262bbfc23fb7 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values/agent_activity_colors.xml @@ -0,0 +1,9 @@ + + + #0284C7 + #D97706 + #4F46E5 + #64748B + #059669 + #DC2626 + diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index 734fbbc2f3b2..3e9b3b24c193 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -294,26 +294,183 @@ class AgentNotificationsTest { } @Test - fun longRowsKeepStatusAndBothTitlesWithinTheNotificationWidth() { + fun severalThreadsListEveryRowWithItsStatusAndActionsFollowThePriorityThread() { lifecycle.currentState = Lifecycle.State.RESUMED - val raw = "Approval\t${"Long thread name ".repeat(10)}\t${"Project name ".repeat(10)}" + val title = "A long thread title that should wrap rather than disappear" + val data = update("attention", true) + mapOf( + "activity_line_0" to "Approval $title Project", + "activity_line_1" to "Working Another thread Other project", + "activity_phase" to "waiting_for_approval", + "activity_active_count" to "8", + "activity_attention_count" to "1", + ) + AgentNotifications.receive(context, data) + val card = manager.activeNotifications.single().notification + assertEquals("1 needs you", card.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + assertEquals("8 active", card.extras.getString(Notification.EXTRA_SUB_TEXT)) + assertEquals( + "Approval $title · Project\nWorking Another thread · Other project", + card.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString() + ) + assertEquals( + "Approval $title · Project", + card.extras.getCharSequence(Notification.EXTRA_TEXT).toString() + ) + assertEquals(listOf("Approve", "Dismiss"), card.actions.map { it.title.toString() }) + assertEquals( + "t3code-dev://threads/environment/thread", + shadowOf(card.actions[0].actionIntent).savedIntent.dataString + ) + assertEquals("Approve", card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + AgentNotifications.receive( context, - update("long-work", true) + (0..4).associate { "activity_line_$it" to raw } - ) - val lines = manager.activeNotifications.single().notification.extras.getString( - Notification.EXTRA_BIG_TEXT - )!!.split('\n') - assertEquals(5, lines.size) - for (line in lines) { - assertTrue(line.startsWith("Approval: ")) - assertTrue(line.contains(" · ")) - assertTrue(line.length < raw.length) - assertFalse(line.contains('\t')) - assertTrue(line.substringAfter(" · ").isNotBlank()) + data + mapOf( + "activity_phase" to "waiting_for_input", + "activity_attention_count" to "2", + "activity_path" to "/threads/another-environment/another-thread", + ) + ) + val next = manager.activeNotifications.single().notification + assertEquals("2 need you", next.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + assertEquals("Answer", next.actions[0].title.toString()) + assertEquals( + "t3code-dev://threads/another-environment/another-thread", + shadowOf(next.actions[0].actionIntent).savedIntent.dataString + ) + assertEquals("Answer", next.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + } + + @Test + fun waitingCardsIgnoreMutableThreadTimestampsEvenAfterRename() { + lifecycle.currentState = Lifecycle.State.RESUMED + val now = System.currentTimeMillis() + for (phase in listOf("waiting_for_approval", "waiting_for_input")) { + val status = if (phase == "waiting_for_approval") "Approval" else "Input" + for ((title, updatedAt) in listOf("Original" to now - 1200000L, "Renamed" to now)) { + AgentNotifications.receive( + context, + update("waiting", true) + mapOf( + "activity_line_0" to "$status\t$title\tProject", + "activity_phase" to phase, + "activity_since" to updatedAt.toString() + ) + ) + val card = manager.activeNotifications.single().notification + assertEquals(title, card.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + assertFalse(card.extras.getBoolean(Notification.EXTRA_SHOW_WHEN)) + assertFalse(card.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER)) + } } } + @Test + fun aSingleThreadUsesItsTitleAndNeverShowsAProgressBar() { + lifecycle.currentState = Lifecycle.State.RESUMED + val data = update("work", true) + mapOf( + "activity_line_0" to "Working Update dashboard Project", + "activity_phase" to "running", + ) + AgentNotifications.receive(context, data) + val working = manager.activeNotifications.single().notification + assertEquals( + "Update dashboard", + working.extras.getCharSequence(Notification.EXTRA_TITLE).toString() + ) + assertEquals( + "Working Project", + working.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString() + ) + assertEquals(null, working.extras.getString(Notification.EXTRA_SUB_TEXT)) + assertEquals("Working", working.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + assertFalse(working.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER)) + for (phase in listOf( + "waiting_for_approval", + "waiting_for_input", + "stale", + "failed", + "completed" + )) { + val active = phase != "failed" && phase != "completed" + AgentNotifications.receive( + context, + data + mapOf( + "activity_phase" to phase, + "active" to active.toString(), + "activity_expires_at" to (System.currentTimeMillis() + 900000).toString(), + ) + ) + val card = manager.activeNotifications.single().notification + assertEquals( + "android.app.Notification\$BigTextStyle", + card.extras.getString(Notification.EXTRA_TEMPLATE) + ) + assertEquals(active, NotificationCompat.isRequestPromotedOngoing(card)) + if (!active) { + assertEquals(null, card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + assertEquals(0, card.actions?.size ?: 0) + } + } + } + + @Test + fun olderRelayRowsStillSelectTheNativeStateAndAction() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive( + context, + update("input", true) + mapOf( + "activity_line_0" to "Input Choose an icon Project", + ) + ) + val card = manager.activeNotifications.single().notification + assertEquals("Choose an icon", card.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + assertEquals("Answer", card.actions.first().title.toString()) + assertEquals(2, card.actions.size) + assertFalse(card.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER)) + } + + @Test + fun multipleAgentsUseTheChipCountAndFinishedThreadsRemainInTheSummary() { + lifecycle.currentState = Lifecycle.State.RESUMED + val data = update("multiple", true) + mapOf( + "activity_line_0" to "Working Build feature Project", + "activity_line_1" to "Done Write tests Project", + "activity_phase" to "running", + "activity_active_count" to "2", + ) + AgentNotifications.receive(context, data) + val card = manager.activeNotifications.single().notification + assertEquals("2 live", card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + assertEquals("2 working", card.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + assertEquals("Project · 2 active", card.extras.getString(Notification.EXTRA_SUB_TEXT)) + assertEquals( + "Working Build feature\nDone Write tests", + card.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString() + ) + AgentNotifications.receive(context, data + ("activity_active_count" to "15")) + assertEquals( + "9+ live", + manager.activeNotifications.single().notification.extras + .getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT) + ) + AgentNotifications.receive( + context, + data + mapOf( + "activity_line_0" to "Failed Build feature Project", + "activity_phase" to "failed", + "activity_active_count" to "0", + "active" to "false", + "activity_expires_at" to (System.currentTimeMillis() + 900000).toString(), + ) + ) + val finished = manager.activeNotifications.single().notification + assertEquals( + "Finished, 1 failed", + finished.extras.getCharSequence(Notification.EXTRA_TITLE).toString() + ) + assertEquals("Project · 2 threads", finished.extras.getString(Notification.EXTRA_SUB_TEXT)) + } + private fun assertTimeout(card: Notification, expected: LongRange) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { assertTrue(card.timeoutAfter in expected) @@ -399,6 +556,46 @@ class AgentNotificationsTest { assertEquals(Notification.VISIBILITY_PRIVATE, card.visibility) } + @Test + fun liveUpdateChipChangesWithActivityAndClearsOnCompletion() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive(context, update("work", true)) + assertEquals( + "Active", + manager.activeNotifications.single().notification.extras + .getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT) + ) + AgentNotifications.receive(context, update("input", true) + ("activity_chip" to "Review")) + val activeCard = manager.activeNotifications.single().notification + assertEquals( + "Review", + activeCard.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT) + ) + assertTrue(NotificationCompat.isRequestPromotedOngoing(activeCard)) + + AgentNotifications.receive( + context, + update("done", false) + mapOf( + "activity_chip" to "Review", + "activity_expires_at" to (System.currentTimeMillis() + 900000).toString() + ) + ) + val finishedCard = manager.activeNotifications.single().notification + assertEquals(null, finishedCard.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + assertFalse(NotificationCompat.isRequestPromotedOngoing(finishedCard)) + assertFalse(finishedCard.flags and Notification.FLAG_ONGOING_EVENT != 0) + } + + @Test + fun blankTitleCannotMakeAnActivityIneligibleForPromotion() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive(context, update("work", true) + ("activity_title" to " ")) + assertEquals( + "Agent activity", + manager.activeNotifications.single().notification.extras.getString(Notification.EXTRA_TITLE) + ) + } + @Test fun expiredMalformedAndFutureMessagesCannotDisplayOrPoisonLaterUpdates() { val invalid = update("invalid", true) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index 523f0d61e0b6..6f2428a6a7de 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -87,6 +87,9 @@ public class T3ComposerEditorModule: Module { Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in view.setSpellCheck(spellCheck) } + Prop("enterBehavior") { (view: T3ComposerEditorView, behavior: String) in + view.setEnterBehavior(behavior) + } Prop("textPasteThresholdBytes") { (view: T3ComposerEditorView, threshold: Int) in view.setTextPasteThresholdBytes(threshold) } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 6258c81c8f97..50ac2afbcb46 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -45,6 +45,11 @@ private struct ComposerChipStyle { let textColor: UIColor } +private enum ComposerEnterBehavior: String { + case send + case newline +} + private final class ComposerTextAttachment: NSTextAttachment { let source: String let label: String @@ -93,10 +98,12 @@ private final class ComposerTextView: UITextView { var isReadOnly = false var textPasteThresholdBytes = 0 var maxInputChars = Int.max + var enterBehavior: ComposerEnterBehavior = .send private var bypassTextPasteInterception = false override var keyCommands: [UIKeyCommand]? { var commands = super.keyCommands ?? [] + guard !isReadOnly, markedTextRange == nil else { return commands } let submit = UIKeyCommand( input: "\r", modifierFlags: .command, @@ -105,6 +112,25 @@ private final class ComposerTextView: UITextView { submit.discoverabilityTitle = "Send Message" submit.wantsPriorityOverSystemBehavior = true commands.append(submit) + if enterBehavior == .send { + let submitOnReturn = UIKeyCommand( + input: "\r", + modifierFlags: [], + action: #selector(submitMessage(_:)) + ) + submitOnReturn.discoverabilityTitle = "Send Message" + submitOnReturn.wantsPriorityOverSystemBehavior = true + commands.append(submitOnReturn) + + let newline = UIKeyCommand( + input: "\r", + modifierFlags: .shift, + action: #selector(insertNewline(_:)) + ) + newline.discoverabilityTitle = "New Line" + newline.wantsPriorityOverSystemBehavior = true + commands.append(newline) + } if textPasteThresholdBytes > 0 { let pasteAsText = UIKeyCommand( input: "v", @@ -119,9 +145,15 @@ private final class ComposerTextView: UITextView { } @objc private func submitMessage(_ sender: UIKeyCommand) { + guard !isReadOnly, markedTextRange == nil else { return } onSubmit?() } + @objc private func insertNewline(_ sender: UIKeyCommand) { + guard !isReadOnly, markedTextRange == nil else { return } + insertText("\n") + } + @objc private func pasteInline(_ sender: UIKeyCommand) { guard !isReadOnly else { return @@ -132,6 +164,9 @@ private final class ComposerTextView: UITextView { } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(submitMessage(_:)) || action == #selector(insertNewline(_:)) { + return isEditable && !isReadOnly && markedTextRange == nil + } if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) { return false } @@ -657,6 +692,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.spellCheckingType = spellCheck ? .yes : .no } + func setEnterBehavior(_ behavior: String) { + textView.enterBehavior = ComposerEnterBehavior(rawValue: behavior) ?? .send + } + func setTextPasteThresholdBytes(_ threshold: Int) { textView.textPasteThresholdBytes = threshold } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h b/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h index 09ddd9c0fe86..625b7ca97c7b 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h @@ -102,6 +102,7 @@ static inline NSAttributedString *T3MarkdownTextAttachmentString( static UIFont *T3ContextChipFont(NSDictionary *payload) { CGFloat size = MAX(10, MIN(40, [payload[@"fontSize"] doubleValue])); + size *= payload[@"fontSizeMultiplier"] != nil ? [payload[@"fontSizeMultiplier"] doubleValue] : 1; return [UIFont fontWithName:@"DMSans-Medium" size:size] ?: [UIFont systemFontOfSize:size weight:UIFontWeightMedium]; } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index 6afd92eb94b5..e1cc7c2046b2 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -197,12 +197,19 @@ static void applyAttachments( } if (props.nativeId.rfind("t3-chip:", 0) == 0 && fragmentLength > 0) { const std::string uri = props.nativeId.substr(3); - NSDictionary *payload = T3ContextChipPayload([NSString stringWithUTF8String:uri.c_str()]); + NSMutableDictionary *payload = + [T3ContextChipPayload([NSString stringWithUTF8String:uri.c_str()]) mutableCopy]; + // Chips must scale with the paragraph or smaller Dynamic Type sizes clip them. + // Store the scaled payload so measurement and the rendered bitmap use the same font. + payload[@"fontSizeMultiplier"] = @(fontSizeMultiplier); + NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; + NSString *scaledUri = [@"chip:" stringByAppendingString: + [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]]; const CGFloat maxWidth = std::isfinite(layoutConstraints.maximumSize.width) ? layoutConstraints.maximumSize.width : 320; const CGSize size = T3ContextChipSize(payload, maxWidth); attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset, 1, uri, false, + utf16Offset, 1, std::string(scaledUri.UTF8String), false, static_cast(size.width), static_cast(size.height), }); } else if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift index f902579f4287..8626a7091567 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -20,11 +20,26 @@ public final class T3KeyboardCommandsView: ExpoView { public override var canBecomeFirstResponder: Bool { true } + public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss), + let input = window?.t3FirstResponder as? UITextInput, + input.markedTextRange != nil { + return false + } + return super.canPerformAction(action, withSender: sender) + } + public override var keyCommands: [UIKeyCommand]? { - [ + let isPad = UIDevice.current.userInterfaceIdiom == .pad + var commands = [ enabledCommand("newTask", input: "n", modifiers: .command, action: #selector(newTask), title: "New Task"), enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"), - enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"), + isPad + ? enabledCommand("commandPalette", input: "k", modifiers: .command, action: #selector(openCommandPalette), title: "Command Palette") + : enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"), + enabledCommand("paletteNext", input: UIKeyCommand.inputDownArrow, modifiers: [], action: #selector(paletteNext), title: "Next Result"), + enabledCommand("palettePrevious", input: UIKeyCommand.inputUpArrow, modifiers: [], action: #selector(palettePrevious), title: "Previous Result"), + enabledCommand("paletteDismiss", input: UIKeyCommand.inputEscape, modifiers: [], action: #selector(paletteDismiss), title: "Close Command Palette"), enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"), enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"), enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"), @@ -38,6 +53,18 @@ public final class T3KeyboardCommandsView: ExpoView { ), enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), ].compactMap { $0 } + if isPad { + commands += (1...9).compactMap { index in + enabledCommand( + "thread.jump.\(index)", + input: String(index), + modifiers: .command, + action: #selector(jumpToThread(_:)), + title: "Go to Thread \(index)" + ) + } + } + return commands } func setEnabledCommands(_ commands: [String]) { @@ -108,6 +135,14 @@ public final class T3KeyboardCommandsView: ExpoView { } @objc private func newTask() { emit("newTask") } + @objc private func openCommandPalette() { emit("commandPalette") } + @objc private func paletteNext() { emit("paletteNext") } + @objc private func palettePrevious() { emit("palettePrevious") } + @objc private func paletteDismiss() { emit("paletteDismiss") } + @objc private func jumpToThread(_ sender: UIKeyCommand) { + guard let input = sender.input else { return } + emit("thread.jump.\(input)") + } @objc private func focusSearch() { emit("focusSearch") } @objc private func goBack() { emit("back") } @objc private func openFiles() { emit("files") } diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 852b6c0560e3..897dae47d57f 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -38,6 +38,8 @@ void SplashScreen.preventAutoHideAsync().catch(() => { const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], + // Keep the compact thread list available beneath a directly opened thread. + config: { initialRouteName: "Home" }, // The Expo dev client launches the app via // ://expo-development-client/?url= — that URL addresses // the launcher, not app navigation. Without this filter it falls through diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 049cd2888962..71efbb11cff2 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -23,7 +23,10 @@ import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardi import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; -import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider"; +import { + HardwareKeyboardCommandOverlay, + HardwareKeyboardCommandProvider, +} from "./features/keyboard/HardwareKeyboardCommandProvider"; import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet"; import { ReviewSheet } from "./features/review/ReviewSheet"; import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; @@ -56,6 +59,7 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; +import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsOpenSourceLicenseRouteScreen, @@ -192,6 +196,13 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Project Grouping", }, }), + SettingsKeyboard: createNativeStackScreen({ + screen: SettingsKeyboardRouteScreen, + linking: "keyboard", + options: { + title: "Keyboard", + }, + }), SettingsClientStorage: createNativeStackScreen({ screen: SettingsClientStorageRouteScreen, linking: "client-storage", @@ -381,17 +392,20 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ ]); /** - * Pathname of the topmost NON-overlay route — the screen the workspace is - * actually "on", regardless of any sheets floating above it. + * Location of the topmost non-overlay route, including its key so thread + * selection can dismiss sheets without replacing the wrong destination. */ -function workspacePathFromState(state: NavigationState): string { +function workspaceLocationFromState(state: NavigationState) { const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name)); const effectiveState = routes.length > 0 && routes.length !== state.routes.length ? ({ ...state, routes, index: routes.length - 1 } as NavigationState) : state; const path = getPathFromState(effectiveState, navigationPathConfig); - return path.startsWith("/") ? path : `/${path}`; + return { + pathname: path.startsWith("/") ? path : `/${path}`, + routeKey: effectiveState.routes[effectiveState.index]?.key, + }; } // The drain hook subscribes to the outbox, all thread shells, projects, and @@ -435,15 +449,19 @@ function RootStackLayout(props: { // workspace layout only reacts to the underlying non-overlay route. const path = getPathFromState(props.state, navigationPathConfig); const pathname = path.startsWith("/") ? path : `/${path}`; - const workspacePathname = workspacePathFromState(props.state); + const workspaceLocation = workspaceLocationFromState(props.state); return ( - + {props.children} + diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 725b3b84389b..f9112d78fb41 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -1,4 +1,6 @@ import { ComposerContextId } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -19,6 +21,7 @@ import { useComposerDraft, } from "../state/use-composer-drafts"; import { importComposerContextClipboard } from "../lib/composerContextClipboard"; +import { mobilePreferencesAtom } from "../state/preferences"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./AppText"; import { @@ -53,6 +56,10 @@ export function ComposerEditor({ ...props }: ComposerEditorProps) { const draft = useComposerDraft(draftKey ?? null); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const preferredEnterBehavior = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.composerEnterBehavior + : undefined; const contextHistory = useMemo(() => createComposerDraftContextHistory(), [draftKey]); useEffect(() => () => contextHistory.dispose(), [contextHistory]); const changeText = (text: string) => { @@ -158,6 +165,7 @@ export function ComposerEditor({ <> void; + readonly onRetry: () => void; + readonly onRemove: () => void; +}) { + const { clone } = props; + const name = projectCloneDisplayName(clone); + if (clone.phase === "running") { + return ( + + + + + Cloning {name} + + + {projectCloneProgressSummary(clone)} + + + + + ); + } + const cancelled = clone.phase === "cancelled"; + return ( + + + {cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`} + + {clone.error ? ( + + {clone.error} + + ) : null} + + + + + + ); +} + +function BannerAction(props: { readonly label: string; readonly onPress: () => void }) { + return ( + + {props.label} + + ); +} diff --git a/apps/mobile/src/components/RowPressable.tsx b/apps/mobile/src/components/RowPressable.tsx new file mode 100644 index 000000000000..bd52fbe74f11 --- /dev/null +++ b/apps/mobile/src/components/RowPressable.tsx @@ -0,0 +1,35 @@ +import type { ComponentProps, ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { GestureDetector } from "react-native-gesture-handler"; + +import { cn } from "../lib/cn"; +import { useHoverGesture } from "../lib/useHoverGesture"; + +/** Pointer feedback layered over selection. Touch-down may be the start of a scroll. */ +export function RowPressable({ + children, + className, + interactionClassName = "bg-primary", + ...props +}: Omit, "children"> & { + readonly children: ReactNode; + readonly interactionClassName?: string; +}) { + const { hovered, hoverGesture } = useHoverGesture(props.disabled ?? false); + return ( + + + {({ pressed }) => ( + <> + + {children} + + )} + + + ); +} diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx new file mode 100644 index 000000000000..04a562956c46 --- /dev/null +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -0,0 +1,74 @@ +import { Platform, Pressable, View } from "react-native"; +import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated"; +import { AppText as Text } from "./AppText"; +import { cn } from "../lib/cn"; + +export function SegmentedControl(props: { + readonly options: readonly { + readonly value: Value; + readonly label: string; + readonly accessibilityLabel?: string; + }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; + /** The tab bar is full height; filters under it are shorter so it stays primary. */ + readonly size?: "default" | "compact"; + /** "tab" for the view switcher; filters stay plain buttons. */ + readonly role?: "tab" | "button"; + readonly className?: string; +}) { + const compact = props.size === "compact"; + return ( + + option.value === props.selected), + ) * + 100) / + props.options.length + }%`, + }} + /> + {props.options.map((option) => { + const active = option.value === props.selected; + return ( + props.onSelect(option.value)} + className={cn( + "flex-1 items-center justify-center rounded-full", + compact ? "h-9" : "h-11", + )} + > + + {option.label} + + + ); + })} + + ); +} diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts index 5bb472d3a4fa..c8a2eedd4cb5 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts @@ -2,7 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ os: "android", - native: null as { configure?: ReturnType; clear?: ReturnType } | null, + version: 36, + openSettings: vi.fn(), + native: null as { + configure?: ReturnType; + clear?: ReturnType; + openLiveUpdateSettings?: ReturnType; + } | null, config: { scheme: ["t3code-preview"], extra: { iosPersonalTeamBuild: false } }, requireModule: vi.fn(), })); @@ -10,7 +16,11 @@ const mocks = vi.hoisted(() => ({ vi.mock("expo", () => ({ requireOptionalNativeModule: mocks.requireModule })); vi.mock("expo-constants", () => ({ default: { expoConfig: mocks.config } })); vi.mock("react-native", () => ({ + Linking: { openSettings: mocks.openSettings }, Platform: { + get Version() { + return mocks.version; + }, get OS() { return mocks.os; }, @@ -20,12 +30,39 @@ vi.mock("react-native", () => ({ beforeEach(() => { vi.resetModules(); mocks.os = "android"; + mocks.version = 36; + mocks.openSettings.mockReset().mockResolvedValue(undefined); mocks.native = { configure: vi.fn(), clear: vi.fn() }; mocks.config.extra.iosPersonalTeamBuild = false; mocks.requireModule.mockReset().mockImplementation(() => mocks.native); }); describe("Android native notification capability", () => { + it("opens the Live Update controls on supported Android builds", async () => { + mocks.native!.openLiveUpdateSettings = vi.fn(() => true); + const { openAndroidLiveUpdateSettings, supportsAndroidLiveUpdateSettings } = + await import("./androidNotifications"); + expect(supportsAndroidLiveUpdateSettings()).toBe(true); + await openAndroidLiveUpdateSettings(); + expect(mocks.native!.openLiveUpdateSettings).toHaveBeenCalledOnce(); + expect(mocks.openSettings).not.toHaveBeenCalled(); + mocks.version = 35; + expect(supportsAndroidLiveUpdateSettings()).toBe(false); + mocks.os = "ios"; + mocks.version = 36; + expect(supportsAndroidLiveUpdateSettings()).toBe(false); + }); + + it.each([undefined, vi.fn(() => false)])( + "falls back to app settings for older binaries or missing system activities (%j)", + async (openLiveUpdateSettings) => { + if (openLiveUpdateSettings) mocks.native!.openLiveUpdateSettings = openLiveUpdateSettings; + const { openAndroidLiveUpdateSettings } = await import("./androidNotifications"); + await openAndroidLiveUpdateSettings(); + expect(mocks.openSettings).toHaveBeenCalledOnce(); + }, + ); + it("uses the installed module and the build variant's deep-link scheme", async () => { const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } = await import("./androidNotifications"); diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts index a65ff1758e78..9ffe586ecb81 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -1,10 +1,11 @@ import Constants from "expo-constants"; import { requireOptionalNativeModule } from "expo"; -import { Platform } from "react-native"; +import { Linking, Platform } from "react-native"; interface AndroidAgentNotifications { configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void; clear(): void; + openLiveUpdateSettings?(): boolean; } const native = @@ -33,3 +34,13 @@ export function configureAndroidAgentNotifications( export function clearAndroidAgentNotifications(): void { native?.clear?.(); } + +export function supportsAndroidLiveUpdateSettings(): boolean { + return Platform.OS === "android" && Number(Platform.Version) >= 36; +} + +export async function openAndroidLiveUpdateSettings(): Promise { + if (!native?.openLiveUpdateSettings?.()) { + await Linking.openSettings(); + } +} diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx new file mode 100644 index 000000000000..702aa5f5e8ef --- /dev/null +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -0,0 +1,273 @@ +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { MenuAction } from "@react-native-menu/menu"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { type ReactNode, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + Pressable, + RefreshControl, + ScrollView, + Text, + View, +} from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { showConfirmDialog } from "../../components/ConfirmDialogHost"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + deregisterManagedRelayEnvironmentCommand, + useManagedRelayEnvironments, +} from "./managedRelayState"; + +const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); + +function linkedAtLabel(value: string): string { + const linkedAt = new Date(value); + return Number.isNaN(linkedAt.getTime()) + ? "Link date unavailable" + : `Linked ${linkedAtFormatter.format(linkedAt)}`; +} + +function endpointLabel(environment: RelayClientEnvironmentRecord): string { + return environment.endpoint.providerKind === "cloudflare_tunnel" + ? "Managed tunnel" + : "Activity publishing only"; +} + +function confirmDeregister(environment: RelayClientEnvironmentRecord, onConfirm: () => void) { + const title = "Deregister server?"; + const message = `“${environment.label}” will be removed from this account. T3 Connect access will be revoked, any managed tunnel will be removed, and a host space will become available. Local connections on your devices are not changed.`; + if (process.env.EXPO_OS === "ios") { + Alert.alert(title, message, [ + { text: "Cancel", style: "cancel" }, + { text: "Deregister", style: "destructive", onPress: onConfirm }, + ]); + return; + } + showConfirmDialog({ title, message, confirmText: "Deregister", destructive: true, onConfirm }); +} + +/** + * The "T3 Connect" custom page inside Clerk's native user profile: every + * environment registered to the signed-in account, with account-level + * deregistration. Mirrors the web UserButton page; connections on this device + * are managed in Settings instead. + */ +export function T3ConnectProfilePage() { + const environmentsState = useManagedRelayEnvironments(); + const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { + reportFailure: false, + }); + const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = + useState(null); + const mutationPendingRef = useRef(false); + // Deregistered rows stay in the cached list until the refresh lands, so hide + // them by the linkedAt they had. A re-link produces a new linkedAt and shows again. + const [removedEnvironments, setRemovedEnvironments] = useState<{ + readonly accountId: string | null; + readonly linkedAtById: ReadonlyMap; + }>({ accountId: null, linkedAtById: new Map() }); + + const handleDeregister = async (environment: RelayClientEnvironmentRecord) => { + const accountId = environmentsState.accountId; + if (!accountId || mutationPendingRef.current) return; + + mutationPendingRef.current = true; + setDeregisteringEnvironmentId(environment.environmentId); + const result = await deregisterEnvironment({ + accountId, + environmentId: environment.environmentId, + }); + mutationPendingRef.current = false; + setDeregisteringEnvironmentId(null); + + if (result._tag === "Success") { + setRemovedEnvironments((current) => { + const linkedAtById = new Map(current.accountId === accountId ? current.linkedAtById : []); + linkedAtById.set(environment.environmentId, environment.linkedAt); + return { accountId, linkedAtById }; + }); + environmentsState.refresh(); + return; + } + if (isAtomCommandInterrupted(result)) return; + + const cause = squashAtomCommandFailure(result); + const message = cause instanceof Error ? cause.message : "Could not deregister the server."; + const traceId = findErrorTraceId(cause); + console.error("[t3-connect] Could not deregister environment", { + environmentId: environment.environmentId, + message, + traceId, + cause, + }); + Alert.alert( + "Could not deregister server", + traceId ? `${message}\n\nTrace ID: ${traceId}` : message, + traceId + ? [ + { + text: "Copy trace ID", + onPress: () => copyTextWithHaptic(traceId, { target: "connection-trace-id" }), + }, + { text: "OK", style: "cancel" }, + ] + : undefined, + ); + }; + + const removedEnvironmentLinkedAt = + removedEnvironments.accountId === environmentsState.accountId + ? removedEnvironments.linkedAtById + : new Map(); + const environments = (environmentsState.data ?? []).filter( + (environment) => + removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt, + ); + const isInitialLoad = + !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error); + const errorTraceId = environmentsState.errorTraceId; + + return ( + + } + > + Registered servers + + {environmentsState.error ? ( + <> + + {errorTraceId ? ( + { + copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); + }} + /> + ) : null} + + ) : isInitialLoad ? ( + + + Loading environments + + ) : environments.length > 0 ? ( + environments.map((environment) => ( + + ) : ( + + confirmDeregister(environment, () => void handleDeregister(environment)) + } + > + + + + + + + ) + } + /> + )) + ) : ( + + )} + + + Connections on this device are managed in Settings. + + + ); +} + +const ENVIRONMENT_MENU_ACTIONS = [ + { id: "deregister", title: "Deregister", image: "trash", attributes: { destructive: true } }, +] satisfies MenuAction[]; + +// Layout primitives that mirror clerk-ios ClerkKitUI's profile rows so a custom +// page reads as one of Clerk's own screens. System font on purpose: Clerk's +// native views do not use the app's DM Sans. + +function ClerkSectionHeader(props: { readonly children: string }) { + return ( + + {props.children} + + ); +} + +function ClerkRow(props: { + readonly title: string; + readonly subtitle: string; + readonly accessory?: ReactNode; +}) { + return ( + + + + {props.title} + + + {props.subtitle} + + + {props.accessory} + + ); +} + +function ClerkButtonRow(props: { readonly label: string; readonly onPress: () => void }) { + return ( + + {props.label} + + ); +} diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 8c41d74841e7..375bda715e12 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -1,9 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, + deregisterManagedRelayEnvironment, managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect } from "react"; @@ -19,6 +25,22 @@ export const managedRelayQueryManager = createManagedRelayQueryManager(managedRe cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }), }); +const managedRelayMutationScheduler = createAtomCommandScheduler(); + +export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand( + managedRelayAtomRuntime, + { + label: "mobile:managed-relay:deregister-environment", + scheduler: managedRelayMutationScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) => + input.accountId, + }, + execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input), + }, +); + const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null")); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4e01ef6077da..2b933c50315e 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -37,6 +37,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -775,6 +776,11 @@ export function HomeScreen(props: HomeScreenProps) { [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], ); + useThreadJumpShortcuts( + threadListV2Enabled ? threadListV2Items : listLayout.items, + props.onSelectThread, + ); + const renderV2Item = useCallback( ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => { const nextItem = threadListV2Items[index + 1]; diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index eb1722c73fde..d1e3315fd59a 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -15,6 +15,7 @@ import { type HomeListItem, } from "./homeListItems"; import type { HomeThreadGroup } from "./homeThreadList"; +import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; const environmentId = EnvironmentId.make("environment-1"); @@ -87,6 +88,28 @@ function displayStates( return new Map(Object.entries(entries)); } +describe("threadJumpTarget", () => { + it("numbers only displayed threads across groups, skipping collapsed groups and pagination rows", () => { + const layout = buildHomeListLayout({ + groups: [makeGroup("collapsed", 3), makeGroup("alpha", 8), makeGroup("beta", 3)], + displayStates: displayStates({ collapsed: { collapsed: true, visibleCount: 6 } }), + }); + expect(threadJumpTarget(layout.items, "thread.jump.1")?.id).toBe("alpha-thread-0"); + expect(threadJumpTarget(layout.items, "thread.jump.7")?.id).toBe("beta-thread-0"); + expect(threadJumpTarget(layout.items, "thread.jump.9")?.id).toBe("beta-thread-2"); + }); + + it("ignores missing positions and unrelated commands", () => { + const layout = buildHomeListLayout({ + groups: [makeGroup("alpha", 1)], + displayStates: displayStates({}), + }); + expect(threadJumpTarget(layout.items, "thread.jump.2")).toBeNull(); + expect(threadJumpTarget([], "thread.jump.1")).toBeNull(); + expect(threadJumpTarget(layout.items, "commandPalette")).toBeNull(); + }); +}); + describe("buildHomeListLayout", () => { it("renders a header plus all threads for a small group without a show-more row", () => { const layout = buildHomeListLayout({ diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx new file mode 100644 index 000000000000..a79e99fc212a --- /dev/null +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -0,0 +1,477 @@ +import { useNavigation } from "@react-navigation/native"; +import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + FlatList, + KeyboardAvoidingView, + Modal, + Pressable, + Text as NativeText, + TextInput, + useWindowDimensions, + View, +} from "react-native"; + +import { GestureHandlerRootView } from "react-native-gesture-handler"; + +import { GlassSurface } from "../../components/GlassSurface"; +import { RowPressable } from "../../components/RowPressable"; +import { AppText as Text } from "../../components/AppText"; +import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; +import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; +import { useProjects, useThreadShell, useThreadShells } from "../../state/entities"; +import { useThreadSearch } from "../../state/queries"; +import { useWorkspaceState } from "../../state/workspace"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match"; +import { + filterCommandPaletteItems, + nextPaletteIndex, + type CommandPaletteItem, +} from "./commandPaletteItems"; +import { parseActiveThreadPath, type HardwareKeyboardCommand } from "./hardwareKeyboardCommands"; +import { threadJumpIndex } from "./threadKeyboardShortcuts"; + +const PALETTE_COMMANDS: ReadonlyArray = [ + "commandPalette", + "paletteDismiss", + "paletteNext", + "palettePrevious", + ...THREAD_JUMP_KEYBINDING_COMMANDS, +]; +const ROW_HEIGHT = 50; + +const ACTION_ICONS: Record = { + newTask: "square.and.pencil", + newThread: "square.and.pencil", + addProject: "folder.badge.plus", + settings: "gearshape", + appearance: "paintbrush", + environments: "desktopcomputer", + usage: "chart.bar.xaxis", + archive: "archivebox", + files: "doc.text", + terminal: "terminal", + review: "arrow.triangle.pull", + copyThreadReference: "link", +}; + +function itemIcon(item: CommandPaletteItem): AppSymbolName { + if (item.kind === "project") return "folder"; + if (item.kind === "thread") return "text.bubble"; + return ACTION_ICONS[item.key] ?? "ellipsis"; +} + +function PaletteRow(props: { + readonly item: CommandPaletteItem; + readonly index: number; + readonly selected: boolean; + readonly searchMatch?: EnvironmentThreadSearchMatch; + readonly searchQuery: string; + readonly onSelect: () => void; +}) { + return ( + + + + + + + {props.item.title} + + {props.searchMatch ? ( + + ) : props.item.detail ? ( + + {props.item.detail} + + ) : null} + + {props.index < 9 ? ( + + ⌘{props.index + 1} + + ) : null} + + ); +} + +/** Mounted only while open, so the app root does not subscribe to the full thread catalog. */ +export function CommandPalette(props: { + readonly pathname: string; + readonly onClose: () => void; + readonly onCommand: (command: HardwareKeyboardCommand) => void; +}) { + const navigation = useNavigation(); + const { selectThread } = useAdaptiveWorkspaceLayout(); + const runCommand = props.onCommand; + const projects = useProjects(); + const threads = useThreadShells(); + const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]); + const activeThread = useThreadShell(activeThreadRef); + const { environments } = useWorkspaceState(); + const { savedConnectionsById } = useSavedRemoteConnections(); + const [query, setQuery] = useState(""); + const [selection, setSelection] = useState(null); + const [visible, setVisible] = useState(true); + const pendingAction = useRef<(() => void) | null>(null); + const closing = useRef(false); + const inputRef = useRef(null); + const listRef = useRef>(null); + const { width, height } = useWindowDimensions(); + const searchEnvironmentIds = useMemo( + () => + environments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + [environments], + ); + const search = useThreadSearch(searchEnvironmentIds, query.startsWith(">") ? "" : query); + const matchedThreadKeys = useMemo( + () => + new Set(search.matches.map((match) => scopedThreadKey(match.environmentId, match.threadId))), + [search.matches], + ); + const contentMatchByKey = useMemo( + () => + new Map( + search.matches + .filter((match) => match.source === "user" || match.source === "assistant") + .map((match) => [scopedThreadKey(match.environmentId, match.threadId), match]), + ), + [search.matches], + ); + const items = useMemo(() => { + const actions: CommandPaletteItem[] = [ + { + key: "newTask", + kind: "action", + title: "New thread in…", + searchTerms: ["new task", "chat", "create", "project"], + run: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), + }, + { + key: "addProject", + kind: "action", + title: "Add project", + searchTerms: ["folder", "clone", "repository", "git"], + run: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }), + }, + { + key: "settings", + kind: "action", + title: "Open settings", + searchTerms: ["preferences", "configuration"], + run: () => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "Settings" }, + }), + }, + { + key: "appearance", + kind: "action", + title: "Appearance", + searchTerms: ["theme", "colors", "dark", "light"], + run: () => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsAppearance" }, + }), + }, + { + key: "environments", + kind: "action", + title: "Manage environments", + searchTerms: ["connections", "server", "remote"], + run: () => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }), + }, + { + key: "usage", + kind: "action", + title: "Usage", + searchTerms: ["limits", "accounts", "quota"], + run: () => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsUsage" }, + }), + }, + { + key: "archive", + kind: "action", + title: "Archived threads", + searchTerms: ["restore", "history"], + run: () => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsArchive" }, + }), + }, + ]; + const projectByKey = new Map( + projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]), + ); + const activeProject = activeThread + ? projectByKey.get(scopedProjectKey(activeThread.environmentId, activeThread.projectId)) + : null; + if (activeProject) { + actions.unshift({ + key: "newThread", + kind: "action", + title: `New thread in ${activeProject.title}`, + searchTerms: ["new task", "chat", "create"], + run: () => + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: activeProject.environmentId, + projectId: activeProject.id, + title: activeProject.title, + }, + }), + }); + } + if (activeThreadRef) { + const threadActions = [ + ["files", "Go to file", ["open", "files", "browse", "search"]], + ["terminal", "Open terminal", ["shell", "console"]], + ["review", "Review changes", ["diff", "git", "pull request"]], + ["copyThreadReference", "Copy PR link or thread ID", ["reference", "clipboard"]], + ] as const; + actions.push( + ...threadActions.map(([command, title, searchTerms]) => ({ + key: command, + kind: "action" as const, + title, + searchTerms, + run: () => runCommand(command), + })), + ); + } + const projectItems: CommandPaletteItem[] = projects.map((project) => ({ + key: `project:${scopedProjectKey(project.environmentId, project.id)}`, + kind: "project", + title: project.title, + detail: `New thread · ${savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId}`, + searchTerms: [project.workspaceRoot, "new thread", "project"], + run: () => + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: project.environmentId, + projectId: project.id, + title: project.title, + }, + }), + })); + const threadItems: CommandPaletteItem[] = threads + .filter((thread) => thread.archivedAt === null) + .sort((left, right) => + (right.latestUserMessageAt ?? right.updatedAt).localeCompare( + left.latestUserMessageAt ?? left.updatedAt, + ), + ) + .map((thread) => { + const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); + const environment = + savedConnectionsById[thread.environmentId]?.environmentLabel ?? thread.environmentId; + return { + key: scopedThreadKey(thread.environmentId, thread.id), + kind: "thread", + title: thread.title || "Untitled thread", + detail: [project?.title, environment].filter(Boolean).join(" · "), + searchTerms: [ + project?.title ?? "", + environment, + thread.branch ?? "", + ...threadPullRequestSearchTerms(thread), + ], + run: () => selectThread(thread), + }; + }); + return [...actions, ...projectItems, ...threadItems]; + }, [ + activeThread, + activeThreadRef, + navigation, + projects, + runCommand, + savedConnectionsById, + selectThread, + threads, + ]); + const results = useMemo( + () => filterCommandPaletteItems(items, query, matchedThreadKeys), + [items, matchedThreadKeys, query], + ); + const selectedIndex = Math.max( + 0, + results.findIndex((item) => item.key === selection), + ); + const selectedKey = results[selectedIndex]?.key; + useEffect(() => { + if (selectedIndex === 0) { + // Centering before the list measures its height scrolls half the first row out of view. + listRef.current?.scrollToOffset({ offset: 0, animated: false }); + } else if (selectedKey !== undefined) { + listRef.current?.scrollToIndex({ index: selectedIndex, animated: false, viewPosition: 0.5 }); + } + }, [selectedIndex, selectedKey]); + + const dismissed = useRef(false); + const handleDismissed = useCallback(() => { + if (dismissed.current) return; + dismissed.current = true; + // Present navigation sheets only after UIKit has dismissed this modal. + props.onClose(); + pendingAction.current?.(); + }, [props]); + + // iOS drops Modal onDismiss when the VC is dismissed mid-presentation (e.g. + // ⌘K during the fade-in) or raced by another sheet — without a fallback the + // palette stays mounted-but-invisible and ⌘K dead-ends on a stale open state. + useEffect(() => { + if (visible) return; + const fallback = setTimeout(handleDismissed, 400); + return () => clearTimeout(fallback); + }, [visible, handleDismissed]); + + function close(run?: () => void) { + if (closing.current) return; + closing.current = true; + pendingAction.current = run ?? null; + setVisible(false); + } + + function onCommand(command: HardwareKeyboardCommand) { + if (command === "commandPalette" || command === "paletteDismiss") { + close(); + } else if (command === "paletteNext" || command === "palettePrevious") { + setSelection( + results[nextPaletteIndex(selectedIndex, command === "paletteNext" ? 1 : -1, results.length)] + ?.key ?? null, + ); + } else { + const item = results[threadJumpIndex(command)]; + if (item) close(item.run); + } + } + + return ( + inputRef.current?.focus()} + onRequestClose={() => close()} + onDismiss={handleDismissed} + > + + + + close()} + /> + + + + + { + setQuery(value); + setSelection(null); + listRef.current?.scrollToOffset({ offset: 0, animated: false }); + }} + returnKeyType="go" + submitBehavior="submit" + onSubmitEditing={() => { + const item = results[selectedIndex]; + if (item) close(item.run); + }} + /> + + + item.key} + getItemLayout={(_, index) => ({ + length: ROW_HEIGHT, + offset: ROW_HEIGHT * index, + index, + })} + contentContainerClassName="pb-2" + ListEmptyComponent={ + + {search.isPending ? "Searching…" : "No results"} + + } + renderItem={({ item, index }) => ( + close(item.run)} + /> + )} + /> + + + + + + ); +} diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 585f55a1265c..8a6aa14dbdd9 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -1,6 +1,8 @@ import { StackActions, useNavigation } from "@react-navigation/native"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { + createContext, + use, useCallback, useEffect, useMemo, @@ -8,6 +10,7 @@ import { useState, useSyncExternalStore, type PropsWithChildren, + type ReactNode, } from "react"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; @@ -15,6 +18,7 @@ import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useThreadShell } from "../../state/entities"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay"; +import { CommandPalette } from "./CommandPalette"; import { dispatchHardwareKeyboardCommand, getHardwareKeyboardCommandRegistrationVersion, @@ -31,11 +35,20 @@ const EMPTY_COPY_FEEDBACK: GitActionProgress = { }; const COPY_FEEDBACK_DISMISS_MS = 3_000; +const CommandPaletteContext = createContext(null); + +/** Render inside the workspace so palette actions share its navigation and pane state. */ +export function HardwareKeyboardCommandOverlay() { + return use(CommandPaletteContext); +} + export function HardwareKeyboardCommandProvider({ children, pathname, }: PropsWithChildren<{ readonly pathname: string }>) { const navigation = useNavigation(); + const [paletteOpen, setPaletteOpen] = useState(false); + const closePalette = useCallback(() => setPaletteOpen(false), []); const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]); const activeThread = useThreadShell(activeThreadRef); const copyTarget = useMemo( @@ -86,6 +99,12 @@ export function HardwareKeyboardCommandProvider({ const enabledCommands = useMemo(() => { const commands = new Set(getRegisteredHardwareKeyboardCommands()); commands.add("newTask"); + commands.add("commandPalette"); + if (pathname !== "/" && !pathname.startsWith("/threads/")) { + for (const command of commands) { + if (command.startsWith("thread.jump.")) commands.delete(command); + } + } if (pathname !== "/" || navigation.canGoBack()) commands.add("back"); if (activeThreadRef !== null) { commands.add("files"); @@ -94,10 +113,14 @@ export function HardwareKeyboardCommandProvider({ if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference"); } return [...commands]; - }, [pathname, registrationVersion, navigation]); + }, [activeThreadRef, pathname, registrationVersion, navigation]); const onCommand = useCallback( (command: HardwareKeyboardCommand) => { + if (command === "commandPalette") { + setPaletteOpen(true); + return; + } if (dispatchHardwareKeyboardCommand(command)) return; if (command === "copyThreadReference") { @@ -152,12 +175,20 @@ export function HardwareKeyboardCommandProvider({ [copyTarget, navigation, pathname, showCopyFeedback], ); + const palette = useMemo( + () => + paletteOpen ? ( + + ) : null, + [closePalette, onCommand, paletteOpen, pathname], + ); + return ( - <> + {children} - + ); } diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts new file mode 100644 index 000000000000..2a383b80369d --- /dev/null +++ b/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + filterCommandPaletteItems, + nextPaletteIndex, + type CommandPaletteItem, +} from "./commandPaletteItems"; + +function item( + key: string, + title: string, + kind: CommandPaletteItem["kind"], + searchTerms: string[] = [], +): CommandPaletteItem { + return { key, title, kind, searchTerms, run: () => {} }; +} + +const items = [ + item("new", "New thread in…", "action", ["project", "create"]), + item("settings", "Open settings", "action", ["preferences"]), + item("project", "Mobile app", "project", ["/workspaces/mobile", "new thread"]), + item("siva:one", "Keyboard shortcuts", "thread", ["Mobile app", "Siva"]), + item("mac:one", "Mobile app", "thread", ["Mac"]), +]; +const emptyMatches = new Set(); + +describe("filterCommandPaletteItems", () => { + it("shows actions and recent threads in their original order when the query is empty", () => { + expect(filterCommandPaletteItems(items, "", emptyMatches).map((item) => item.key)).toEqual([ + "new", + "settings", + "siva:one", + "mac:one", + ]); + }); + + it("matches query tokens across titles and metadata and ranks exact titles first", () => { + expect( + filterCommandPaletteItems(items, " MOBILE app ", emptyMatches).map((item) => item.key), + ).toEqual(["project", "mac:one", "siva:one"]); + expect( + filterCommandPaletteItems(items, "siva keyboard", emptyMatches).map((item) => item.key), + ).toEqual(["siva:one"]); + }); + + it("supports the desktop actions-only prefix and action aliases", () => { + expect(filterCommandPaletteItems(items, ">", emptyMatches).map((item) => item.key)).toEqual([ + "new", + "settings", + ]); + expect( + filterCommandPaletteItems(items, "> preferences", emptyMatches).map((item) => item.key), + ).toEqual(["settings"]); + expect( + filterCommandPaletteItems(items, "> new thread", emptyMatches).map((item) => item.key), + ).toEqual(["new"]); + }); + + it("includes server content matches scoped to the correct environment, except in actions-only mode", () => { + const matches = new Set(["siva:one", "project"]); + expect( + filterCommandPaletteItems(items, "message content", matches).map((item) => item.key), + ).toEqual(["siva:one"]); + expect(filterCommandPaletteItems(items, "> message content", matches)).toEqual([]); + }); +}); + +describe("nextPaletteIndex", () => { + it("wraps arrow navigation in both directions and handles empty results", () => { + expect(nextPaletteIndex(0, -1, 3)).toBe(2); + expect(nextPaletteIndex(2, 1, 3)).toBe(0); + expect(nextPaletteIndex(0, 1, 3)).toBe(1); + expect(nextPaletteIndex(0, -1, 0)).toBe(0); + expect(nextPaletteIndex(0, 1, 0)).toBe(0); + }); +}); diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.ts new file mode 100644 index 000000000000..6a06fd4e3387 --- /dev/null +++ b/apps/mobile/src/features/keyboard/commandPaletteItems.ts @@ -0,0 +1,46 @@ +export interface CommandPaletteItem { + readonly key: string; + readonly kind: "action" | "project" | "thread"; + readonly title: string; + readonly detail?: string; + readonly searchTerms: ReadonlyArray; + readonly run: () => void; +} + +/** `>` narrows to actions, matching the desktop palette. Stable ties retain recent-thread order. */ +export function filterCommandPaletteItems( + items: ReadonlyArray, + query: string, + matchedThreadKeys: ReadonlySet, +) { + const actionsOnly = query.startsWith(">"); + const normalized = (actionsOnly ? query.slice(1) : query).trim().toLocaleLowerCase(); + const tokens = normalized.split(/\s+/); + return items + .flatMap((item, index) => { + if (actionsOnly && item.kind !== "action") return []; + if (!normalized) return item.kind === "project" ? [] : [{ item, rank: 0, index }]; + const title = item.title.toLocaleLowerCase(); + const haystack = [title, ...item.searchTerms].join(" ").toLocaleLowerCase(); + if ( + !tokens.every((token) => haystack.includes(token)) && + !(item.kind === "thread" && matchedThreadKeys.has(item.key)) + ) + return []; + const rank = + title === normalized + ? 3 + : title.startsWith(normalized) + ? 2 + : title.includes(normalized) + ? 1 + : 0; + return [{ item, rank, index }]; + }) + .sort((left, right) => right.rank - left.rank || left.index - right.index) + .map(({ item }) => item); +} + +export function nextPaletteIndex(index: number, direction: -1 | 1, count: number) { + return count === 0 ? 0 : (index + direction + count) % count; +} diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts index 51deeb8166e7..cfd4199ccee7 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -1,7 +1,12 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type ThreadJumpKeybindingCommand } from "@t3tools/contracts"; import { useEffect } from "react"; export type HardwareKeyboardCommand = + | ThreadJumpKeybindingCommand + | "commandPalette" + | "paletteNext" + | "palettePrevious" + | "paletteDismiss" | "newTask" | "focusSearch" | "back" @@ -11,7 +16,7 @@ export type HardwareKeyboardCommand = | "copyThreadReference" | "toggleSidebar"; -type CommandHandler = () => boolean | void; +type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void; const handlers = new Map>(); const registrationListeners = new Set<() => void>(); @@ -22,18 +27,24 @@ let registrationVersion = 0; * the first chance to consume the command, allowing focused screens to override app defaults. */ export function useHardwareKeyboardCommand( - command: HardwareKeyboardCommand, + command: HardwareKeyboardCommand | ReadonlyArray, handler: CommandHandler, ): void { useEffect(() => { - const commandHandlers = handlers.get(command) ?? new Set(); - commandHandlers.add(handler); - handlers.set(command, commandHandlers); + const commands = typeof command === "string" ? [command] : command; + for (const command of commands) { + const commandHandlers = handlers.get(command) ?? new Set(); + commandHandlers.add(handler); + handlers.set(command, commandHandlers); + } registrationVersion += 1; registrationListeners.forEach((listener) => listener()); return () => { - commandHandlers.delete(handler); - if (commandHandlers.size === 0) handlers.delete(command); + for (const command of commands) { + const commandHandlers = handlers.get(command); + commandHandlers?.delete(handler); + if (commandHandlers?.size === 0) handlers.delete(command); + } registrationVersion += 1; registrationListeners.forEach((listener) => listener()); }; @@ -58,7 +69,7 @@ export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand if (!commandHandlers) return false; // `.reverse()` on a copy, not `.toReversed()`: Hermes has no ES2023 array methods. for (const handler of [...commandHandlers].reverse()) { - if (handler() !== false) return true; + if (handler(command) !== false) return true; } return false; } diff --git a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts new file mode 100644 index 000000000000..b76d2f8a90bf --- /dev/null +++ b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts @@ -0,0 +1,49 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import type { HomeListItem } from "../home/homeListItems"; +import type { ThreadListV2ListItem } from "../threads/threadListV2"; +import { + useHardwareKeyboardCommand, + type HardwareKeyboardCommand, +} from "./hardwareKeyboardCommands"; + +type ThreadShortcutListItem = + | HomeListItem + | ThreadListV2ListItem + | { readonly type: "v2-show-more" }; + +export function threadJumpIndex(command: HardwareKeyboardCommand) { + return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command); +} + +/** Uses the rendered list so filters, collapsed groups and shelves keep their order. */ +export function threadJumpTarget( + items: ReadonlyArray, + command: HardwareKeyboardCommand, +) { + let index = threadJumpIndex(command); + if (index < 0) return null; + for (const item of items) { + const thread = + item.type === "thread" ? item.thread : item.type === "v2-thread" ? item.item.thread : null; + if (thread !== null && index-- === 0) return thread; + } + return null; +} + +export function useThreadJumpShortcuts( + items: ReadonlyArray, + onSelectThread: (thread: EnvironmentThreadShell) => void, +) { + const jumpToThread = useCallback( + (command: HardwareKeyboardCommand) => { + const thread = threadJumpTarget(items, command); + if (thread !== null) onSelectThread(thread); + return true; + }, + [items, onSelectThread], + ); + useHardwareKeyboardCommand(THREAD_JUMP_KEYBINDING_COMMANDS, jumpToThread); +} diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c030e49c2b77..185a3b157fc2 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -6,6 +6,7 @@ import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3too import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { + CommonActions, NavigationContext, NavigationRouteContext, StackActions, @@ -39,7 +40,10 @@ import { type WorkspaceAuxiliaryPaneRole, type WorkspacePaneLayout, } from "../../lib/layout"; -import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; +import { + resolveThreadSelectionNavigationAction, + resolveThreadSelectionOverlayState, +} from "../../lib/adaptive-navigation"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { mobilePreferencesAtom } from "../../state/preferences"; import { @@ -63,6 +67,7 @@ interface AdaptiveWorkspaceContextValue { readonly panes: WorkspacePaneLayout; readonly fileInspector: FileInspectorPaneLayout; readonly primarySidebarSearchQuery: string; + readonly selectThread: (thread: EnvironmentThreadShell) => void; readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; /** * Route screens hand their inspector pane content to the workspace so it @@ -96,6 +101,7 @@ const AdaptiveWorkspaceContext = createContext({ panes: compactPanes, fileInspector: compactFileInspector, primarySidebarSearchQuery: "", + selectThread: () => undefined, activateAuxiliaryPaneRole: () => () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, @@ -198,6 +204,7 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string; + readonly workspaceRouteKey: string | undefined; }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); if (!AsyncResult.isSuccess(preferencesResult)) { @@ -221,6 +228,7 @@ function AdaptiveWorkspaceLayoutContent( props: { readonly children: ReactNode; readonly pathname: string; + readonly workspaceRouteKey: string | undefined; } & { readonly projectGroupingMode: SidebarProjectGroupingMode; }, @@ -408,35 +416,6 @@ function AdaptiveWorkspaceLayoutContent( }, [auxiliaryPaneRole], ); - const contextValue = useMemo( - () => ({ - layout, - panes, - fileInspector, - primarySidebarSearchQuery, - activateAuxiliaryPaneRole, - registerWorkspaceInspector, - setPrimarySidebarSearchQuery, - showAuxiliaryPane, - toggleAuxiliaryPane, - togglePrimarySidebar, - setAuxiliaryPaneWidth, - }), - [ - activateAuxiliaryPaneRole, - fileInspector, - layout, - panes, - primarySidebarSearchQuery, - registerWorkspaceInspector, - showAuxiliaryPane, - setPrimarySidebarSearchQuery, - setAuxiliaryPaneWidth, - toggleAuxiliaryPane, - togglePrimarySidebar, - ], - ); - const handleOpenSettings = useCallback(() => { navigation.navigate("SettingsSheet", { screen: "SettingsContent", @@ -526,6 +505,17 @@ function AdaptiveWorkspaceLayoutContent( usesSplitView: layout.usesSplitView, pathname, }); + const overlayState = resolveThreadSelectionOverlayState({ + state: navigation.getState(), + workspaceRouteKey: props.workspaceRouteKey, + action: navigationAction, + params, + }); + if (overlayState !== null) { + setFileInspectorPreferredVisible(false); + navigation.dispatch(CommonActions.reset(overlayState)); + return; + } if (navigationAction === "set-params") { const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id); if (nextThreadKey === selectedThreadKey) { @@ -542,7 +532,38 @@ function AdaptiveWorkspaceLayoutContent( } navigation.navigate("Thread", params); }, - [layout.usesSplitView, pathname, navigation, selectedThreadKey], + [layout.usesSplitView, pathname, navigation, selectedThreadKey, props.workspaceRouteKey], + ); + + const contextValue = useMemo( + () => ({ + layout, + panes, + fileInspector, + primarySidebarSearchQuery, + selectThread: handleSelectThread, + activateAuxiliaryPaneRole, + registerWorkspaceInspector, + setPrimarySidebarSearchQuery, + showAuxiliaryPane, + toggleAuxiliaryPane, + togglePrimarySidebar, + setAuxiliaryPaneWidth, + }), + [ + activateAuxiliaryPaneRole, + fileInspector, + handleSelectThread, + layout, + panes, + primarySidebarSearchQuery, + registerWorkspaceInspector, + showAuxiliaryPane, + setPrimarySidebarSearchQuery, + setAuxiliaryPaneWidth, + toggleAuxiliaryPane, + togglePrimarySidebar, + ], ); return ( diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx index 63966282266f..cf640f19106a 100644 --- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx +++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx @@ -20,7 +20,6 @@ interface WorkspacePaneDividerProps { export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { const latestProps = useRef(props); latestProps.current = props; - const [hovered, setHovered] = useState(false); const [dragging, setDragging] = useState(false); const handleResizeStart = useCallback(() => { setDragging(true); @@ -63,7 +62,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { return ( setHovered(true)} - onHoverOut={() => setHovered(false)} > diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index b78fb2d308e1..265889ce425f 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -49,7 +49,7 @@ import * as Order from "effect/Order"; import { AsyncResult } from "effect/unstable/reactivity"; import { cn } from "../../lib/cn"; -import { useProjects, useServerConfigs } from "../../state/entities"; +import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; @@ -77,6 +77,8 @@ interface EnvironmentOption { readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; + /** Server runs clones in the background and streams progress; older servers block. */ + readonly supportsCloneTracking: boolean; } const environmentOptionOrder = Order.mapInput( @@ -367,6 +369,7 @@ function useEnvironmentOptions(): ReadonlyArray { connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, + supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true, }; }); return Arr.sort(options, environmentOptionOrder); @@ -576,6 +579,15 @@ export function AddProjectSourceScreen() { ); } +function openNewTaskDraft( + navigation: { dispatch: (action: ReturnType) => void }, + params: { environmentId: EnvironmentId; projectId: ProjectId; title: string; cloning?: "1" }, +) { + navigation.dispatch( + CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }), + ); +} + function useCreateProject(environment: EnvironmentOption | null) { const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); @@ -909,6 +921,10 @@ export function AddProjectDestinationScreen(props: { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); + const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { + reportFailure: false, + }); + const navigation = useNavigation(); const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); @@ -939,6 +955,48 @@ export function AddProjectDestinationScreen(props: { } setIsSubmitting(true); + if (environment.supportsCloneTracking) { + // The server creates the project and clones in the background; the + // draft screen shows progress and holds Start until the files land. + const projectId = ProjectId.make(uuidv4()); + const title = inferProjectTitleFromPath(resolved.path); + const startResult = await startProjectClone({ + environmentId: environment.environmentId, + input: { + projectId, + title, + createdAt: new Date().toISOString(), + remoteUrl, + destinationPath: resolved.path, + }, + }); + if (AsyncResult.isFailure(startResult)) { + setError(errorMessage(Cause.squash(startResult.cause))); + } else { + // The draft screen resolves its project from the client store, so it + // must not open before the create event has arrived (it would fall + // back to the project picker and lose the clone controls). Stay in + // the submitting state until then; the clone keeps running either way. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + setError( + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + } else { + openNewTaskDraft(navigation, { + environmentId: environment.environmentId, + projectId, + title, + cloning: "1", + }); + } + } + setIsSubmitting(false); + return; + } const cloneResult = await cloneRepository({ environmentId: environment.environmentId, input: { @@ -961,8 +1019,10 @@ export function AddProjectDestinationScreen(props: { environment, isBrowseNavigating, isSubmitting, + navigation, pathInput, remoteUrl, + startProjectClone, ]); return ( diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index 96d612f8c689..e6e23fd78be9 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -1,10 +1,21 @@ import { useAuth } from "@clerk/expo"; -import { AuthView, UserProfileView } from "@clerk/expo/native"; +import { AuthView, type UserProfileCustomPage, UserProfileView } from "@clerk/expo/native"; import { StackActions, useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { View } from "react-native"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { T3ConnectProfilePage } from "../cloud/T3ConnectProfilePage"; + +// Custom rows in Clerk's native profile. Mirrors the web UserButton pages. +const USER_PROFILE_CUSTOM_PAGES = [ + { + path: "t3-connect", + label: "T3 Connect", + icon: "globe", + content: , + }, +] satisfies UserProfileCustomPage[]; export function SettingsAuthRouteScreen() { const navigation = useNavigation(); @@ -40,7 +51,11 @@ function ConfiguredSettingsAuthRouteScreen() { {isLoaded ? ( hasBeenSignedIn.current ? ( - + ) : ( ) diff --git a/apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx new file mode 100644 index 000000000000..abd6ac22266d --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx @@ -0,0 +1,101 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { useNavigation } from "@react-navigation/native"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { Platform, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { + DEFAULT_COMPOSER_ENTER_BEHAVIOR, + type ComposerEnterBehavior, +} from "../../lib/composerEnterBehavior"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { SettingsSection } from "./components/SettingsSection"; + +const ENTER_BEHAVIOR_OPTIONS: ReadonlyArray<{ + readonly behavior: ComposerEnterBehavior; + readonly label: string; + readonly description: string; +}> = [ + { + behavior: "send", + label: "Send message", + description: "Return sends the message. Shift-Return inserts a new line.", + }, + { + behavior: "newline", + label: "Insert new line", + description: "Return inserts a new line. Command-Return sends the message.", + }, +]; + +export function SettingsKeyboardRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; + const selectedBehavior = AsyncResult.isSuccess(preferencesResult) + ? (preferencesResult.value.composerEnterBehavior ?? DEFAULT_COMPOSER_ENTER_BEHAVIOR) + : null; + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + + {ENTER_BEHAVIOR_OPTIONS.map((option, index) => ( + savePreferences({ composerEnterBehavior: option.behavior })} + className={ + index === 0 + ? "flex-row items-center gap-4 p-4" + : "flex-row items-center gap-4 border-t border-border-subtle p-4" + } + > + + {option.label} + + {option.description} + + + {selectedBehavior === option.behavior ? ( + + ) : null} + + ))} + + + Applies to the composer when a hardware keyboard is connected. + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index d343f2dc8830..dc4f1d2ee32b 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -21,6 +21,10 @@ import { import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; +import { + openAndroidLiveUpdateSettings, + supportsAndroidLiveUpdateSettings, +} from "../agent-awareness/androidNotifications"; import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; import { @@ -541,7 +545,13 @@ function ConfiguredSettingsRouteScreen() { liveActivityStatus === "linking" } icon="bolt.circle" - label={Platform.OS === "android" ? "Ongoing Agent Activity" : "Live Activity Updates"} + label={ + Platform.OS === "android" + ? supportsAndroidLiveUpdateSettings() + ? "Agent Live Updates" + : "Ongoing Agent Activity" + : "Live Activity Updates" + } subtitle={agentAwarenessSubtitle} // Same gate: a saved preference is meaningless until the device // registration the relay needs to push updates has succeeded. @@ -552,6 +562,20 @@ function ConfiguredSettingsRouteScreen() { } onValueChange={handleLiveActivitiesChange} /> + {supportsAndroidLiveUpdateSettings() ? ( + { + void openAndroidLiveUpdateSettings().catch(() => { + Alert.alert( + "Couldn't open Settings", + "Open Android Settings, select T3 Code, then enable Live Updates in Notifications.", + ); + }); + }} + /> + ) : null} @@ -574,6 +598,9 @@ function GeneralSettingsSection() { return ( + {Platform.OS === "ios" ? ( + + ) : null} diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index a52ee350f5ae..2ac985a11494 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -2,6 +2,7 @@ export type SettingsSheetTarget = | "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance" + | "SettingsKeyboard" | "SettingsProjectGrouping" | "SettingsClientStorage" | "SettingsDiagnostics" diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx new file mode 100644 index 000000000000..7f5b69ed224a --- /dev/null +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -0,0 +1,268 @@ +import { + Button, + DatePicker, + Host, + HStack, + Menu, + Picker, + Popover, + Spacer, + Text, + VStack, +} from "@expo/ui/swift-ui"; +import { + background, + buttonStyle, + datePickerStyle, + font, + foregroundStyle, + frame, + padding, + pickerStyle, + presentationBackground, + shapes, + tag, +} from "@expo/ui/swift-ui/modifiers"; +import { + localSnoozeDate, + localSnoozeTime, + resolveCustomSnooze, + type CustomSnoozeInput, +} from "@t3tools/client-runtime/state/thread-settled"; +import { useState } from "react"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { AppText } from "../../components/AppText"; +import { SegmentedControl } from "../../components/SegmentedControl"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; + +const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); +const modes = [ + { value: "date", label: "Date and time" }, + { value: "duration", label: "Duration" }, +] as const; +const units = [ + { value: "minutes", label: "Minutes" }, + { value: "hours", label: "Hours" }, + { value: "days", label: "Days" }, +] as const; + +export function CustomSnoozeSheet(props: { + readonly onClose: () => void; + readonly onSnooze: (snoozedUntil: string) => void; +}) { + const [mode, setMode] = useState("date"); + const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); + const [amount, setAmount] = useState(2); + const [amountOpen, setAmountOpen] = useState(false); + const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); + const [error, setError] = useState(null); + const { themeVariables: colors, themeAppearance, appearance } = useAppearancePreferences(); + const updateDate = (value: Date) => { + setDate(value); + setError(null); + }; + + const submit = () => { + const input: CustomSnoozeInput = + mode === "date" + ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) } + : { mode, amount: String(amount), unit }; + const snoozedUntil = resolveCustomSnooze(input, new Date()); + if (!snoozedUntil) { + setError( + mode === "date" ? "Choose a date and time in the future." : "Enter a positive duration.", + ); + return; + } + props.onSnooze(snoozedUntil); + props.onClose(); + }; + + return ( + + + + + + Cancel + + + Custom snooze + + + Snooze + + + { + setMode(value); + setError(null); + }} + role="tab" + /> + + + {mode === "date" ? "Until" : "Snooze for"} + + {mode === "date" ? ( + <> + + + + ) : ( + <> + + + + + + + { + setAmount(value); + setError(null); + }} + modifiers={[pickerStyle("wheel"), frame({ height: 160 })]} + > + {durationAmounts.map((value) => ( + + {String(value)} + + ))} + + + + + + + + ), + }; + } + const cancelled = activeProjectClone.phase === "cancelled"; + return { + id: `project-clone:${projectId}`, + variant: cancelled ? "warning" : "error", + icon: , + title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, + description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, + actions: ( + <> + + + + ), + }; + }, [ + activeProjectClone, + activeProjectRef, + cancelProjectClone, + removeClonedProject, + retryProjectClone, + runProjectCloneAction, + ]); const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); @@ -3032,7 +3151,7 @@ export default function ChatView(props: ChatViewProps) { resetLocalDispatch, localDispatchStartedAt, latestUserMessageAt, - isPreparingWorktree, + isPreparingWorktree: isLocallyPreparingWorktree, isSendBusy, backgroundSubmissionPending, } = useLocalDispatchState({ @@ -3068,8 +3187,30 @@ export default function ChatView(props: ChatViewProps) { (isSendBusy || phase === "connecting" || phase === "running") && compactRequestIsActive && !compactionSettled; + // The server records a running worktree setup on the thread for the whole + // bootstrap window. That record, with no turn yet, is how a reload or another + // client sees a worktree still being prepared, so it counts as working like + // the local dispatch that started it. It settles on every failure path and + // on restart, so this cannot outlive the setup. The placeholder "starting" + // session is not used here: an ordinary first turn projects one too, and it + // already drives the connecting state on its own. + const recordedWorktreeSetup = useMemo( + () => findRecordedWorktreeSetup(activeThread?.activities ?? [], routeThreadRef.threadId), + [activeThread?.activities, routeThreadRef.threadId], + ); + const awaitingBootstrapTurn = + activeServerThread !== null && + activeServerThread.id === routeThreadRef.threadId && + activeServerThread.latestTurn === null && + recordedWorktreeSetup?.phase === "running"; const isWorking = - phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || isCompacting; + phase === "running" || + isSendBusy || + isConnecting || + isRevertingCheckpoint || + isCompacting || + awaitingBootstrapTurn; + const isPreparingWorktree = isLocallyPreparingWorktree || awaitingBootstrapTurn; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -3362,52 +3503,57 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, ); const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); - // Live stages of a bootstrap worktree setup. The subscription follows the - // thread that was set up, not the route: a deleted bootstrap thread rotates - // the draft's thread id, and the failed card must survive that. - const worktreeSetupOwnerKey = draftId ?? routeThreadKey; - const worktreeSetupActive = - worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey; - // The setup runs on the environment that received the dispatch, so both - // the subscription and cancel target that one even if the draft's machine - // picker changes underneath. + // Live stages of a bootstrap worktree setup. A worktree send creates the + // server thread under the route's thread id before anything else, so the + // stream is keyed by that id alone: no owner bookkeeping, and a remount, + // reload, or second client picks it up the same way. The subscription is + // held only while a snapshot can still change. + const routeThreadPreparesWorktree = + (isPreparingWorktree && activeThread?.id === routeThreadRef.threadId) || + heldWorktreeSetup?.phase === "running"; const worktreeSetupQuery = useEnvironmentQuery( - worktreeSetupActive + routeThreadPreparesWorktree ? vcsEnvironment.worktreeSetup({ - environmentId: worktreeSetupRef.environmentId, - input: { threadId: worktreeSetupRef.threadId }, + environmentId: routeThreadRef.environmentId, + input: { threadId: routeThreadRef.threadId }, }) : null, ); const latestWorktreeSetup = worktreeSetupQuery.data; useEffect(() => { - // The server drops finished snapshots after a grace period and emits null. - // Hold the last real snapshot so a settled card does not vanish. if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup); }, [latestWorktreeSetup]); - const worktreeSetup = - worktreeSetupActive && heldWorktreeSetup?.threadId === worktreeSetupRef.threadId - ? heldWorktreeSetup - : null; - // A finished card is dropped once the agent's turn shows in the timeline: - // the card belongs to the send, and the agent takes over from there. - const worktreeSetupDoneAndTurnVisible = - worktreeSetup?.phase === "done" && activeThread?.latestTurn?.startedAt != null; useEffect(() => { - if (!worktreeSetupDoneAndTurnVisible) return; - setWorktreeSetupRef(null); setHeldWorktreeSetup(null); - }, [worktreeSetupDoneAndTurnVisible]); + }, [routeThreadKey]); + const liveWorktreeSetup = + heldWorktreeSetup?.threadId === routeThreadRef.threadId ? heldWorktreeSetup : null; + const worktreeSetup = resolveVisibleWorktreeSetup({ + live: liveWorktreeSetup, + recorded: recordedWorktreeSetup, + turnStarted: activeThread?.latestTurn?.startedAt != null, + isWorking, + }); + // Sends wait for the agent handoff, not for the setup script: an async + // script keeps the snapshot running while the agent already works, and a + // follow-up must not be held behind a slow install. Before the first + // snapshot arrives the starting session stands in for it. + const worktreeSetupBlocksSend = + worktreeSetup !== null + ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup) + : isServerThread && + activeThreadShell?.session?.status === "starting" && + activeThreadShell.latestTurn === null; const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { reportFailure: false, }); const onCancelWorktreeSetup = useCallback(() => { - if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running") return; + if (!worktreeSetup || worktreeSetup.phase !== "running") return; void cancelWorktreeSetup({ - environmentId: worktreeSetupRef.environmentId, + environmentId: routeThreadRef.environmentId, input: { threadId: worktreeSetup.threadId }, }); - }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupRef]); + }, [cancelWorktreeSetup, routeThreadRef.environmentId, worktreeSetup]); // The setup terminal belongs to the thread that was set up. A failed // bootstrap deletes that thread and closes its terminals, so only offer the // terminal while the setup thread is still the active one. @@ -3772,10 +3918,18 @@ export default function ChatView(props: ChatViewProps) { const interruptContextRef = useRef({ activeThread, phase, setThreadError }); interruptContextRef.current = { activeThread, phase, setThreadError }; + const restoreQueuedMessagesRef = useRef<(messages: ReadonlyArray) => void>( + () => {}, + ); const onInterrupt = useCallback(async () => { const { activeThread, phase, setThreadError } = interruptContextRef.current; const input = buildRunningThreadTurnInterruptInput(activeThread, phase); if (!input || !activeThread) return; + restoreQueuedMessagesRef.current( + useQueuedMessageStore + .getState() + .drain(scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id))), + ); const result = await interruptThreadTurn({ environmentId: activeThread.environmentId, input, @@ -6271,10 +6425,12 @@ export default function ChatView(props: ChatViewProps) { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; + const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, ...usageLimitsItems, + ...projectCloneItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -6285,6 +6441,7 @@ export default function ChatView(props: ChatViewProps) { return [ ...feedbackBannerItems, ...usageLimitsItems, + ...projectCloneItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -6337,6 +6494,7 @@ export default function ChatView(props: ChatViewProps) { isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, + projectCloneBannerItem, resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, @@ -6422,6 +6580,17 @@ export default function ChatView(props: ChatViewProps) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); + const getShortcutContext = useCallback( + () => ({ + terminalFocus: getTerminalFocusOwner() !== null, + terminalOpen: Boolean(terminalUiState.terminalOpen), + previewFocus: isPreviewFocused(), + previewOpen: previewPanelOpen, + modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, + }), + [composerRef, previewPanelOpen, terminalUiState.terminalOpen], + ); + useEffect(() => { const handler = (event: globalThis.KeyboardEvent, requestedCommand?: AppKeybindingCommand) => { if (preventRepeatedTerminalCloseShortcut(event, keybindings)) { @@ -6446,13 +6615,7 @@ export default function ChatView(props: ChatViewProps) { if (event.defaultPrevented && !requestedCommand && terminalFocusOwner === null) { return; } - const shortcutContext = { - terminalFocus: terminalFocusOwner !== null, - terminalOpen: Boolean(terminalUiState.terminalOpen), - previewFocus: isPreviewFocused(), - previewOpen: previewPanelOpen, - modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, - }; + const shortcutContext = getShortcutContext(); if ( !vimEnabled() && @@ -6621,7 +6784,33 @@ export default function ChatView(props: ChatViewProps) { if (command === "modelPicker.toggle") { event.preventDefault(); event.stopPropagation(); - composerRef.current?.toggleModelPicker(); + if (!event.repeat) composerRef.current?.toggleModelPicker(); + return; + } + + if ( + command === "composer.host" || + command === "composer.effort" || + command === "composer.mode" || + command === "composer.workspace" + ) { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) composerRef.current?.openControl(command); + return; + } + + if (command === "composer.branch") { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) branchToolbarRef.current?.openBranchPicker(); + return; + } + + if (command === "composer.previousWorktree") { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) branchToolbarRef.current?.usePreviousWorktree(); return; } @@ -6680,7 +6869,7 @@ export default function ChatView(props: ChatViewProps) { supportsSettlement, confirmAndUnpinThread, copyActiveThreadReference, - previewPanelOpen, + getShortcutContext, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, @@ -6938,6 +7127,81 @@ export default function ChatView(props: ChatViewProps) { } }; + const queuedMessages = useQueuedMessages(activeThreadKey ?? ""); + // Puts queued messages back into the composer, e.g. after Stop or a failed + // send. Prompts join with blank lines; attachments and contexts are added. + const restoreQueuedMessagesToComposer = (messages: ReadonlyArray) => { + if (messages.length === 0) return; + const prompts = [promptRef.current, ...messages.map((message) => message.prompt)] + .map((prompt) => prompt.trim()) + .filter((prompt) => prompt.length > 0); + const nextPrompt = prompts.join("\n\n"); + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + // The draft store silently drops attachments over the per-turn cap. Split + // the overflow back into the queue so nothing is lost; the user can send + // the first batch and the rest follows as a queued message. + const attachmentRoom = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - + composerImagesRef.current.length - + composerFilesRef.current.length, + ); + const attachments = messages.flatMap((message) => [...message.images, ...message.files]); + const restored = attachments.slice(0, attachmentRoom); + const overflow = attachments.slice(attachmentRoom); + const restoredImages = restored.filter((attachment) => attachment.type === "image"); + const restoredFiles = restored.filter((attachment) => attachment.type === "file"); + // The composer syncs these refs from the draft in an effect; a send before + // that effect runs must already see the restored content. + composerImagesRef.current = [...composerImagesRef.current, ...restoredImages]; + composerFilesRef.current = [...composerFilesRef.current, ...restoredFiles]; + if (restoredImages.length > 0) addComposerDraftImages(composerDraftTarget, restoredImages); + if (restoredFiles.length > 0) addComposerDraftFiles(composerDraftTarget, restoredFiles); + if (overflow.length > 0 && activeThreadKey) { + useQueuedMessageStore.getState().enqueue(activeThreadKey, { + prompt: "", + images: overflow.filter((attachment) => attachment.type === "image"), + files: overflow.filter((attachment) => attachment.type === "file"), + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + submissionIntent: "foreground", + queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities), + // Restoration is not a send. The user decides when the overflow goes. + holdUntilUserAction: true, + createdAt: new Date().toISOString(), + }); + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Some attachments stayed queued", + description: `A message holds at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments. Use Send now on the queued row when you want the rest to go.`, + }), + ); + } + const restoredTerminalContexts = [ + ...composerTerminalContextsRef.current, + ...messages.flatMap((message) => message.terminalContexts), + ]; + composerTerminalContextsRef.current = restoredTerminalContexts; + setComposerDraftTerminalContexts(composerDraftTarget, restoredTerminalContexts); + const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + setComposerDraftPreviewAnnotations(composerDraftTarget, [ + ...(draft?.previewAnnotations ?? []), + ...messages.flatMap((message) => message.previewAnnotations), + ]); + setComposerDraftReviewComments(composerDraftTarget, [ + ...(draft?.reviewComments ?? []), + ...messages.flatMap((message) => message.reviewComments), + ]); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(nextPrompt, nextPrompt.length), + prompt: nextPrompt, + detectTrigger: true, + }); + }; + const onSend = async ( e?: { preventDefault: () => void }, submissionIntent: ComposerSubmissionIntent = "foreground", @@ -6945,6 +7209,8 @@ export default function ChatView(props: ChatViewProps) { annotation: PreviewAnnotationPayload; image: ComposerImageAttachment | null; }, + /** A queued message being sent now instead of the live composer draft. */ + queuedMessage?: QueuedComposerMessage, ) => { e?.preventDefault(); const focusAtSend = document.activeElement; @@ -6954,6 +7220,7 @@ export default function ChatView(props: ChatViewProps) { usageLimitsOffered && usageLimitsKey !== null && !directAnnotation && + !queuedMessage && !composerHasNonPromptContent && isUsageLimitsCommand(promptRef.current) ) { @@ -7015,7 +7282,9 @@ export default function ChatView(props: ChatViewProps) { return; } if (activePendingProgress) { - if (directAnnotation) { + // A queued message waits until the question is answered; it must not + // be submitted as the answer. + if (directAnnotation || queuedMessage) { notifyDirectAnnotationAttached(); return; } @@ -7033,6 +7302,8 @@ export default function ChatView(props: ChatViewProps) { terminalContexts: composerTerminalContexts, previewAnnotations: sendContextPreviewAnnotations, reviewComments: composerReviewComments, + } = queuedMessage ?? sendCtx; + const { selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -7075,11 +7346,13 @@ export default function ChatView(props: ChatViewProps) { : sendContextPreviewAnnotations; // A direct "send annotation" writes the draft and sends in the same tick; the reference // must be in the text now, not after the next render. - const promptForSend = directAnnotation - ? ensureInlineContextReferences(promptRef.current, [ - previewAnnotationContextReference(directAnnotation.annotation), - ]) - : promptRef.current; + const promptForSend = queuedMessage + ? queuedMessage.prompt + : directAnnotation + ? ensureInlineContextReferences(promptRef.current, [ + previewAnnotationContextReference(directAnnotation.annotation), + ]) + : promptRef.current; const { trimmedPrompt: trimmed, sendableTerminalContexts: sendableComposerTerminalContexts, @@ -7100,7 +7373,7 @@ export default function ChatView(props: ChatViewProps) { composerReviewComments.length === 0 ? parseCodexFeedbackCommand(trimmed) : null; - if (feedbackCommand) { + if (feedbackCommand && !queuedMessage) { if (!isServerThread || activeThread.session === null) { toastManager.add( stackedThreadToast({ @@ -7151,6 +7424,7 @@ export default function ChatView(props: ChatViewProps) { } if ( !directAnnotation && + !queuedMessage && sendInteractionModeEnabled && showPlanFollowUpPrompt && activeProposedPlan && @@ -7222,7 +7496,7 @@ export default function ChatView(props: ChatViewProps) { composerReviewComments.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; - if (standaloneSlashCommand) { + if (standaloneSlashCommand && !queuedMessage) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); @@ -7243,6 +7517,12 @@ export default function ChatView(props: ChatViewProps) { }), ); } + // A queued message whose only content expired would retry on every + // boundary and block the rest of the queue. Nothing sendable is left + // in it, so drop it and let the queue move on. + if (queuedMessage && activeThreadKey) { + useQueuedMessageStore.getState().remove(activeThreadKey, queuedMessage.id); + } return; } if (!activeProject) { @@ -7255,6 +7535,36 @@ export default function ChatView(props: ChatViewProps) { ); return; } + // A send during a running turn waits in the queue. It leaves on the next + // tool boundary, when the turn ends, or when the user clicks Steer. The + // provider treats a mid-turn send as a steer of the active turn, so the + // dispatch below is the same either way. + if (!queuedMessage && !directAnnotation && phase === "running" && activeThreadKey) { + if (composerRef.current?.validateProviderInput(promptForSend) === false) { + return; + } + useQueuedMessageStore.getState().enqueue(activeThreadKey, { + prompt: promptForSend, + images: [...composerImages], + files: [...composerFiles], + terminalContexts: [...composerTerminalContexts], + previewAnnotations: [...composerPreviewAnnotations], + reviewComments: [...composerReviewComments], + submissionIntent, + queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities), + createdAt: new Date().toISOString(), + }); + promptRef.current = ""; + // Attachments move with the message; their uploads stay pending. The + // refs clear now too, so a Stop before the composer's sync effect runs + // does not restore the moved attachments twice. + composerImagesRef.current = []; + composerFilesRef.current = []; + composerTerminalContextsRef.current = []; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + return; + } const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = @@ -7310,6 +7620,11 @@ export default function ChatView(props: ChatViewProps) { text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, }); if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { + // A queued message that no longer fits is held at the head for the + // user to edit via Cancel, instead of failing on every boundary. + if (queuedMessage && activeThreadKey) { + useQueuedMessageStore.getState().holdAtFront(activeThreadKey, queuedMessage); + } return; } @@ -7330,10 +7645,41 @@ export default function ChatView(props: ChatViewProps) { }; sendInFlightRef.current = true; + // Every early return above leaves a queued message in the queue for a + // later retry. From here on a failure hands it back to the composer. + if (queuedMessage) { + const taken = activeThreadKey + ? useQueuedMessageStore + .getState() + .take( + activeThreadKey, + queuedMessage.id, + latestCompletedToolActivityId(threadActivities), + ) + : null; + if (!taken) { + sendInFlightRef.current = false; + return; + } + } + // Stop drains the queue. A queued send whose upload was still running at + // that moment must not start a turn afterwards; it checks this before + // dispatch and hands the message back to the composer instead. + const drainGenerationAtTake = useQueuedMessageStore.getState().drainGeneration; + // A queued send that fails goes back to the head of the queue, held. The + // messages behind it keep their order and wait; the composer is not + // touched, which also keeps a failure after navigation off the new + // thread's draft. The user retries with Send now or edits with Cancel. + const abortQueuedReplay = () => { + if (queuedMessage && activeThreadKey) { + useQueuedMessageStore.getState().holdAtFront(activeThreadKey, queuedMessage); + } + }; const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities(); if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) { sendInFlightRef.current = false; setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason); + abortQueuedReplay(); return; } const turnUsesAttachmentUploads = @@ -7353,15 +7699,26 @@ export default function ChatView(props: ChatViewProps) { if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) { sendInFlightRef.current = false; setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason); + abortQueuedReplay(); return; } if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) { sendInFlightRef.current = false; setThreadError(threadIdForSend, "Retry or remove failed uploads before sending."); + abortQueuedReplay(); return; } } + if ( + queuedMessage && + useQueuedMessageStore.getState().drainGeneration !== drainGenerationAtTake + ) { + sendInFlightRef.current = false; + restoreQueuedMessagesToComposer([queuedMessage]); + return; + } + const resolvedSubmissionIntent = submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; if ( @@ -7394,17 +7751,13 @@ export default function ChatView(props: ChatViewProps) { setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, ); + abortQueuedReplay(); return; } beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree), submissionIntent: resolvedSubmissionIntent, }); - setWorktreeSetupRef( - baseBranchForWorktree - ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey } - : null, - ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); @@ -7499,9 +7852,11 @@ export default function ChatView(props: ChatViewProps) { }), ); } - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); + if (!queuedMessage) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + } let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { @@ -7577,6 +7932,7 @@ export default function ChatView(props: ChatViewProps) { } let turnStartSucceeded = false; + let backgroundDraftOpened = false; if (failure === null && turnAttachmentsResult._tag === "Success") { const bootstrap = isLocalDraftThread || baseBranchForWorktree @@ -7615,7 +7971,7 @@ export default function ChatView(props: ChatViewProps) { if (backgroundThreadRef) { beginBackgroundDraftSubmissionByRef(backgroundThreadRef); } - const startResult = await startThreadTurn({ + const startPromise = startThreadTurn({ environmentId, input: { threadId: threadIdForSend, @@ -7659,10 +8015,32 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }, }); - if (startResult._tag === "Failure") { - if (backgroundThreadRef) { + if (backgroundThreadRef) { + markPromotedDraftThreadByRef(backgroundThreadRef); + try { + backgroundDraftOpened = Boolean( + await handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + resolveBackgroundDraftWorkspaceOptions({ + envMode: sendEnvMode, + branch: activeThreadBranch, + startFromOrigin, + }), + ), + ); + } catch (error) { clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Could not open a fresh composer", + description: error instanceof Error ? error.message : undefined, + }), + ); } + } + const startResult = await startPromise; + if (startResult._tag === "Failure") { failure = startResult; } else { turnStartSucceeded = true; @@ -7675,48 +8053,26 @@ export default function ChatView(props: ChatViewProps) { } acknowledgeActiveThreadWoke(); if (backgroundThreadRef) { - markPromotedDraftThreadByRef(backgroundThreadRef); - try { - const nextDraft = await handleNewThread( - scopeProjectRef(activeProject.environmentId, activeProject.id), - resolveBackgroundDraftWorkspaceOptions({ - envMode: sendEnvMode, - branch: activeThreadBranch, - startFromOrigin, - }), - ); - if (nextDraft) { - finalizePromotedDraftThreadByRef(backgroundThreadRef); - toastManager.add( - stackedThreadToast({ - type: "success", - title: "Started in background", - timeout: 5_000, - actionProps: { - children: "Open", - onClick: () => { - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(backgroundThreadRef), - }); - }, - }, - }), - ); - } else { - clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - } - } catch (error) { + if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) { + finalizePromotedDraftThreadByRef(backgroundThreadRef); + } else { clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - resetLocalDispatch(); + } + if (backgroundDraftOpened) { toastManager.add( stackedThreadToast({ - type: "warning", - title: "Task started in the background", - description: - error instanceof Error - ? `Could not open a fresh composer: ${error.message}` - : "Could not open a fresh composer.", + type: "success", + title: "Started in background", + timeout: 5_000, + actionProps: { + children: "Open", + onClick: () => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(backgroundThreadRef), + }); + }, + }, }), ); } @@ -7725,15 +8081,46 @@ export default function ChatView(props: ChatViewProps) { } if (failure !== null) { - if ( - promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerFilesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations - .length ?? 0) === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments - .length ?? 0) === 0 + if (resolvedSubmissionIntent === "background" && draftId && draftThread) { + restoreFailedBackgroundDraftThread( + draftId, + draftThread, + wasBootstrapThreadDeleted(squashAtomCommandFailure(failure)) + ? newThreadId() + : threadIdForSend, + ); + clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend)); + } + if (queuedMessage) { + setOptimisticUserMessages((existing) => { + const removed = existing.filter((message) => message.id === messageIdForSend); + for (const message of removed) { + revokeUserMessagePreviewUrls(message); + } + const next = existing.filter((message) => message.id !== messageIdForSend); + return next.length === existing.length ? existing : next; + }); + // The optimistic row's preview URLs were just revoked, so the images + // need fresh ones before the row can show them again. + if (activeThreadKey) { + useQueuedMessageStore.getState().holdAtFront(activeThreadKey, { + ...queuedMessage, + images: queuedMessage.images.map(cloneComposerImageForRetry), + }); + } + } else if ( + backgroundDraftOpened + ? !composerDraftHasUserContent( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ) + : promptRef.current.length === 0 && + composerImagesRef.current.length === 0 && + composerFilesRef.current.length === 0 && + composerTerminalContextsRef.current.length === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) + ?.previewAnnotations.length ?? 0) === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments + .length ?? 0) === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -7762,7 +8149,12 @@ export default function ChatView(props: ChatViewProps) { } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); - if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) { + if ( + resolvedSubmissionIntent !== "background" && + isLocalDraftThread && + draftId && + wasBootstrapThreadDeleted(error) + ) { const failedDraftSession = getDraftSession(draftId); if (failedDraftSession?.threadId === threadIdForSend) { setLogicalProjectDraftThreadId( @@ -7780,6 +8172,21 @@ export default function ChatView(props: ChatViewProps) { threadIdForSend, error instanceof Error ? error.message : "Failed to send message.", ); + if (backgroundDraftOpened && draftId) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Background task failed", + description: error instanceof Error ? error.message : "Failed to send message.", + actionProps: { + children: "Open draft", + onClick: () => { + void navigate({ to: "/draft/$draftId", params: { draftId } }); + }, + }, + }), + ); + } } } sendInFlightRef.current = false; @@ -7800,6 +8207,75 @@ export default function ChatView(props: ChatViewProps) { } }; + // Sends the oldest queued message once it is due: a tool call finished + // after it was queued, or the turn ended. Only one leaves per boundary; the + // take inside onSend re-anchors the rest. + const sendQueuedMessage = useEffectEvent((message: QueuedComposerMessage) => { + void onSend(undefined, message.submissionIntent, undefined, message); + }); + const nextQueuedMessage = queuedMessages[0] ?? null; + const latestToolActivityId = useMemo( + () => (nextQueuedMessage ? latestCompletedToolActivityId(threadActivities) : null), + [nextQueuedMessage, threadActivities], + ); + // Approvals and questions block the agent; a steer landing on top of them + // would answer nothing and confuse the turn, so the queue holds until the + // user resolves them. + const queueBlockedByPendingRequest = + activePendingApproval !== null || pendingUserInputs.length > 0; + // onSend bails early on transient gates (environment offline, settings not + // hydrated, checkpoint rewinding, messages loading, machine not chosen) and + // leaves the message queued. Re-run when any of them clear so a due message + // does not wait for an unrelated phase change. + const queueSendGate = + activeEnvironmentUnavailable || + !clientSettingsHydrated || + isRevertingCheckpoint || + threadDetailLoading || + needsLoadBalancing || + activeProviderStatus === null; + useEffect(() => { + if (!nextQueuedMessage || isSendBusy || queueBlockedByPendingRequest || queueSendGate) return; + if (sendInFlightRef.current) return; + if (!isQueuedMessageDue({ message: nextQueuedMessage, phase, latestToolActivityId })) return; + sendQueuedMessage(nextQueuedMessage); + }, [ + isSendBusy, + latestToolActivityId, + nextQueuedMessage, + phase, + queueBlockedByPendingRequest, + queueSendGate, + ]); + + // The row handlers are read from refs at call-time so their identity stays + // stable and does not bust TimelineRowCtx on every ChatView render. + const queuedMessageActionsRef = useRef({ + steer: (_id: string) => {}, + remove: (_id: string) => {}, + }); + queuedMessageActionsRef.current = { + steer: (id) => { + const message = queuedMessages.find((entry) => entry.id === id); + if (!message || sendInFlightRef.current || queueBlockedByPendingRequest) return; + void onSend(undefined, message.submissionIntent, undefined, message); + }, + remove: (id) => { + if (!activeThreadKey) return; + const message = useQueuedMessageStore.getState().remove(activeThreadKey, id); + if (message) restoreQueuedMessagesToComposer([message]); + }, + }; + const onSteerQueuedMessage = useCallback((id: string) => { + queuedMessageActionsRef.current.steer(id); + }, []); + const onRemoveQueuedMessage = useCallback((id: string) => { + queuedMessageActionsRef.current.remove(id); + }, []); + // Stop also cancels the queue: the messages return to the composer instead + // of starting a new turn the moment the interrupted one settles. + restoreQueuedMessagesRef.current = restoreQueuedMessagesToComposer; + const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { if (!activeThreadId) return; @@ -8507,11 +8983,11 @@ export default function ChatView(props: ChatViewProps) { // setup (the bootstrap created it), so this keys off the route, not // `isLocalDraftThread`. const onWorktreeSetupWorkLocally = useCallback(() => { - if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running" || !draftId) { + if (!worktreeSetup || worktreeSetup.phase !== "running" || !draftId) { return; } const target = { - environmentId: worktreeSetupRef.environmentId, + environmentId: routeThreadRef.environmentId, input: { threadId: worktreeSetup.threadId }, }; void (async () => { @@ -8519,7 +8995,7 @@ export default function ChatView(props: ChatViewProps) { if (result._tag !== "Success" || !result.value.cancelled) return; setWorkLocallyResendDraftId(draftId); })(); - }, [cancelWorktreeSetup, draftId, worktreeSetup, worktreeSetupRef]); + }, [cancelWorktreeSetup, draftId, routeThreadRef.environmentId, worktreeSetup]); const onSendRef = useRef(onSend); onSendRef.current = onSend; // Resend once the cancelled dispatch has settled and the composer is free. @@ -8755,6 +9231,10 @@ export default function ChatView(props: ChatViewProps) { // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. { @@ -9072,6 +9552,9 @@ export default function ChatView(props: ChatViewProps) { hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns} + queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages} + onSteerQueuedMessage={onSteerQueuedMessage} + onRemoveQueuedMessage={onRemoveQueuedMessage} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -9179,7 +9662,9 @@ export default function ChatView(props: ChatViewProps) { ? "Sending feedback" : threadDetailLoading ? "Messages loading" - : null + : worktreeSetupBlocksSend + ? "Preparing worktree" + : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} @@ -9284,6 +9769,7 @@ export default function ChatView(props: ChatViewProps) { {mountComposerContextStrip && (
null); + const navigationResult = await settlePromise(() => handleNewThread(projectRef)); + if (navigationResult._tag === "Failure") { + const error = squashAtomCommandFailure(navigationResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } } const browseTo = useCallback( diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx new file mode 100644 index 000000000000..d80ebf0d36fb --- /dev/null +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -0,0 +1,245 @@ +import { useEffect, useId, useState } from "react"; +import { Tabs } from "@base-ui/react/tabs"; +import { create } from "zustand"; +import { + localSnoozeDate, + localSnoozeTime, + resolveCustomSnooze, + type CustomSnoozeInput, +} from "@t3tools/client-runtime/state/thread-settled"; +import { Button } from "./ui/button"; +import { CalendarIcon } from "lucide-react"; +import { Calendar } from "./ui/calendar"; +import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { toggleVariants } from "./ui/toggle"; +import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select"; +import { + NumberField, + NumberFieldGroup, + NumberFieldInput, + NumberFieldDecrement, + NumberFieldIncrement, +} from "./ui/number-field"; +import { + Dialog, + DialogPopup, + DialogHeader, + DialogTitle, + DialogDescription, + DialogPanel, + DialogFooter, +} from "./ui/dialog"; + +type SnoozeChoice = { readonly snoozedUntil: string }; +type Request = { readonly resolve: (choice: SnoozeChoice | null) => void }; +const useRequest = create<{ request: Request | null }>(() => ({ request: null })); + +export function requestCustomSnooze(): Promise { + useRequest.getState().request?.resolve(null); + return new Promise((resolve) => useRequest.setState({ request: { resolve } })); +} + +function finish(choice: SnoozeChoice | null) { + const request = useRequest.getState().request; + useRequest.setState({ request: null }); + request?.resolve(choice); +} + +export function CustomSnoozeDialogHost() { + const request = useRequest((state) => state.request); + useEffect(() => () => finish(null), []); + return request ? : null; +} + +function CustomSnoozeDialog() { + const id = useId(); + const [initial] = useState(() => new Date(Date.now() + 3_600_000)); + const [mode, setMode] = useState("date"); + const [date, setDate] = useState(initial); + const [calendarOpen, setCalendarOpen] = useState(false); + const [time, setTime] = useState(localSnoozeTime(initial)); + const [amount, setAmount] = useState("2"); + const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); + const [error, setError] = useState(null); + const input: CustomSnoozeInput = + mode === "date" ? { mode, date: localSnoozeDate(date), time } : { mode, amount, unit }; + return ( + { + if (!open) finish(null); + }} + > + +
{ + event.preventDefault(); + const snoozedUntil = resolveCustomSnooze(input, new Date()); + if (!snoozedUntil) { + setError( + mode === "date" + ? "Choose a valid date and time in the future." + : "Enter a positive duration.", + ); + return; + } + finish({ snoozedUntil }); + }} + > + + Custom snooze + Choose when snoozed threads return to your inbox. + + + { + if (value === "date" || value === "duration") setMode(value); + setError(null); + }} + className="flex flex-col gap-4" + > + + {(["date", "duration"] as const).map((value) => ( + + {value === "date" ? "Date and time" : "Duration"} + + ))} + + + {mode === "date" ? ( +
+
+ + + + } + > + {date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + { + setDate(selected); + setCalendarOpen(false); + setError(null); + }} + /> + + +
+ +
+ ) : ( +
+ { + setAmount(value === null ? "" : String(value)); + setError(null); + }} + > + + + + + + + + +
+ )} +
+
+ {error && ( +

+ {error} +

+ )} +
+ + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/ProjectCloneToastCoordinator.tsx b/apps/web/src/components/ProjectCloneToastCoordinator.tsx new file mode 100644 index 000000000000..2c4a477a2f2e --- /dev/null +++ b/apps/web/src/components/ProjectCloneToastCoordinator.tsx @@ -0,0 +1,240 @@ +import { useParams } from "@tanstack/react-router"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + projectCloneDisplayName, + projectCloneProgressSummary, + type EnvironmentId, + type ProjectCloneSnapshot, + type ProjectId, +} from "@t3tools/contracts"; +import { useCallback, useEffect, useRef } from "react"; + +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; +import { useEnvironments } from "../state/environments"; +import { useEnvironmentProjectClones } from "../state/projectClones"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { useAtomCommand } from "../state/use-atom-command"; +import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { toastManager } from "./ui/toast"; +import { stackedThreadToast } from "./ui/toastHelpers"; + +/** + * One toast per clone in flight, on every environment. The palette that + * started a clone closes right away, so this is where its progress lives: + * the toast updates in place as git reports stages, then settles into a + * success or failure state with the matching action. + */ +export function ProjectCloneToastCoordinator() { + const { environments } = useEnvironments(); + return environments.map((environment) => ( + + )); +} + +interface TrackedToast { + readonly toastId: ReturnType; + /** The last snapshot rendered, so an identical redraw does not touch the toast. */ + readonly renderedKey: string; + readonly phase: ProjectCloneSnapshot["phase"]; +} + +function renderKey(clone: ProjectCloneSnapshot): string { + return `${clone.phase}:${clone.stage}:${clone.percent ?? ""}:${clone.detail ?? ""}:${clone.error ?? ""}`; +} + +function EnvironmentCloneToasts({ environmentId }: { environmentId: EnvironmentId }) { + const clones = useEnvironmentProjectClones(environmentId); + const handleNewThread = useNewThreadHandler(); + const { draftId: routeDraftId } = useParams({ strict: false }); + const cancelClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { + reportFailure: false, + }); + const retryClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { + reportFailure: false, + }); + // The toast mirrors the server's clone state, so a request that never got + // there needs its own feedback. + const runCloneAction = useCallback( + async (title: string, action: () => Promise>) => { + const result = await action(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [], + ); + const removeClonedProject = useRemoveClonedProject(); + const toasts = useRef(new Map()); + + // Whether the user is already looking at this project's draft: the composer + // banner shows the same progress and actions there, so the toast steps + // aside and comes back if they navigate away mid-clone. + const isViewingProjectDraft = useCallback( + (projectId: ProjectId) => { + if (!routeDraftId) return false; + const draft = useComposerDraftStore.getState().getDraftSession(routeDraftId as DraftId); + return draft?.environmentId === environmentId && draft.projectId === projectId; + }, + [environmentId, routeDraftId], + ); + + const openProject = useCallback( + (projectId: ProjectId) => { + void handleNewThread(scopeProjectRef(environmentId, projectId)); + }, + [environmentId, handleNewThread], + ); + + useEffect(() => { + const seen = new Set(); + for (const clone of clones) { + seen.add(clone.projectId); + const key = renderKey(clone); + const tracked = toasts.current.get(clone.projectId); + const name = projectCloneDisplayName(clone); + // Handlers run later than this pass, so they look the toast up then. + const closeToast = () => { + const current = toasts.current.get(clone.projectId); + if (!current) return; + toastManager.close(current.toastId); + toasts.current.delete(clone.projectId); + }; + if (isViewingProjectDraft(clone.projectId)) { + closeToast(); + continue; + } + if (tracked?.renderedKey === key) continue; + + if (clone.phase === "running") { + const options = stackedThreadToast({ + type: "loading", + title: `Cloning ${name}`, + description: projectCloneProgressSummary(clone), + timeout: 0, + actionProps: { + children: "Cancel", + onClick: () => { + void runCloneAction("Failed to cancel clone", () => + cancelClone({ environmentId, input: { projectId: clone.projectId } }), + ); + }, + }, + data: { hideCopyButton: true }, + }); + if (tracked) { + toastManager.update(tracked.toastId, options); + toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "running" }); + } else { + const toastId = toastManager.add(options); + toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "running" }); + } + continue; + } + + if (clone.phase === "done") { + const options = stackedThreadToast({ + type: "success", + title: `Cloned ${name}`, + description: clone.destinationPath, + timeout: 8_000, + actionProps: { + children: "Open project", + onClick: () => { + closeToast(); + openProject(clone.projectId); + }, + }, + data: { hideCopyButton: true }, + }); + if (tracked) { + toastManager.update(tracked.toastId, options); + toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "done" }); + } else { + const toastId = toastManager.add(options); + toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "done" }); + } + continue; + } + + // Failed or cancelled: the project stays, pointing at an empty folder. + // Retry from here; the draft's composer banner offers the same. + const cancelled = clone.phase === "cancelled"; + const options = stackedThreadToast({ + type: cancelled ? "info" : "error", + title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, + description: cancelled ? clone.destinationPath : (clone.error ?? "The clone failed."), + timeout: 0, + actionProps: { + children: "Retry", + onClick: () => { + void runCloneAction("Failed to retry clone", () => + retryClone({ environmentId, input: { projectId: clone.projectId } }), + ); + }, + }, + data: { + ...(cancelled ? { hideCopyButton: true } : {}), + secondaryActionProps: { + children: "Remove project", + onClick: () => { + // The server drops the clone with the project, which closes + // this toast; a failed removal leaves it (and Retry) in place. + void removeClonedProject({ environmentId, projectId: clone.projectId }); + }, + }, + }, + }); + if (tracked) { + toastManager.update(tracked.toastId, options); + toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: clone.phase }); + } else { + const toastId = toastManager.add(options); + toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: clone.phase }); + } + } + + // A clone the server stopped tracking (done and expired, or its project + // was removed) takes its toast with it, unless it already settled into a + // timed success toast that dismisses itself. + for (const [projectId, tracked] of toasts.current) { + if (seen.has(projectId)) continue; + if (tracked.phase !== "done") toastManager.close(tracked.toastId); + toasts.current.delete(projectId); + } + }, [ + cancelClone, + clones, + environmentId, + isViewingProjectDraft, + openProject, + removeClonedProject, + retryClone, + runCloneAction, + ]); + + useEffect( + () => () => { + for (const tracked of toasts.current.values()) toastManager.close(tracked.toastId); + toasts.current.clear(); + }, + [], + ); + + return null; +} diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 854301bee7bc..b60cf3ae62c4 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -122,36 +122,28 @@ describe("ProjectFavicon", () => { testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; }); - it("shows a project-name icon when no favicon exists", () => { + it("shows the project monogram when no favicon exists", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }), }) as ReactElement<{ - readonly colorClassName?: string; - readonly emoji?: string; - readonly icon?: ComponentType<{ className?: string }>; + readonly projectName?: string; }>; - expect(element.props.icon).toBeDefined(); - expect(element.props.emoji).toBeUndefined(); - expect(element.props.colorClassName).toContain("text-cyan-600"); + expect(element.props.projectName).toBe("analytics-db"); }); - it("chooses a deterministic semantic icon", () => { + it("uses the same monogram fallback for every project category", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }), }) as ReactElement<{ - readonly colorClassName?: string; - readonly emoji?: string; - readonly icon?: ComponentType<{ className?: string }>; + readonly projectName?: string; }>; - expect(element.props.icon).toBeDefined(); - expect(element.props.emoji).toBeUndefined(); - expect(element.props.colorClassName).toContain("text-violet-600"); + expect(element.props.projectName).toBe("agent-runtime"); }); it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 15597e786c31..17006889fb9c 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,39 +1,15 @@ -import type { ProjectIconColor } from "@t3tools/contracts"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { - BotIcon, - BookOpenIcon, - BracesIcon, - CircuitBoardIcon, - CloudCogIcon, - Code2Icon, - DatabaseIcon, - FlaskConicalIcon, - FolderCodeIcon, - Gamepad2Icon, - Globe2Icon, - ImageIcon, - Layers3Icon, - MonitorIcon, - MusicIcon, - PackageIcon, - ServerIcon, - ShieldCheckIcon, - ShoppingBagIcon, - SmartphoneIcon, - TerminalIcon, - VideoIcon, -} from "lucide-react"; +import { FolderCodeIcon } from "lucide-react"; import type { IconName } from "lucide-react/dynamic"; import type { ComponentType } from "react"; import { lazy, Suspense, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { projectFaviconUrlAtom } from "../state/assets"; -import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; +import { deriveProjectIdentity } from "../projectIdentity"; import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; @@ -45,56 +21,6 @@ function DynamicProjectIconFallback() { return ; } -const PROJECT_ICONS: Record> = { - ai: BotIcon, - book: BookOpenIcon, - braces: BracesIcon, - circuit: CircuitBoardIcon, - cloud: CloudCogIcon, - code: Code2Icon, - database: DatabaseIcon, - desktop: MonitorIcon, - "folder-code": FolderCodeIcon, - game: Gamepad2Icon, - image: ImageIcon, - layers: Layers3Icon, - mobile: SmartphoneIcon, - music: MusicIcon, - package: PackageIcon, - security: ShieldCheckIcon, - server: ServerIcon, - shopping: ShoppingBagIcon, - terminal: TerminalIcon, - test: FlaskConicalIcon, - video: VideoIcon, - web: Globe2Icon, -}; - -const PROJECT_ICON_COLOR_BY_NAME: Record = { - ai: "violet", - book: "amber", - braces: "purple", - circuit: "teal", - cloud: "sky", - code: "blue", - database: "cyan", - desktop: "indigo", - "folder-code": "orange", - game: "emerald", - image: "pink", - layers: "fuchsia", - mobile: "lime", - music: "fuchsia", - package: "orange", - security: "teal", - server: "blue", - shopping: "rose", - terminal: "green", - test: "yellow", - video: "red", - web: "sky", -}; - // The slice of a project that decides its icon. Every surface must pass the // project record itself (or a snapshot spread from it) so the saved title, favicon // and icon override always travel together. Passing a display label as the title @@ -103,7 +29,6 @@ export type ProjectFaviconProject = Pick< EnvironmentProject, "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon" >; - export function ProjectFavicon(input: { project: ProjectFaviconProject; className?: string | undefined; @@ -118,7 +43,13 @@ export function ProjectFavicon(input: { }), ); if (project.projectIcon?.kind === "emoji") { - return ; + return ( + + ); } if (project.projectIcon?.kind === "lucide") { const colorClassName = projectIconColorClassName(project.projectIcon.color); @@ -139,25 +70,14 @@ export function ProjectFavicon(input: { ); } - const automaticIconName = input.fallbackIcon - ? null - : selectProjectIcon(project.title, project.workspaceRoot); - const FallbackIcon = - input.fallbackIcon ?? - (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined); - const fallbackEmoji = automaticIconName?.kind === "emoji" ? automaticIconName.emoji : undefined; - const fallbackColorClassName = - automaticIconName?.kind === "lucide" - ? projectIconColorClassName(PROJECT_ICON_COLOR_BY_NAME[automaticIconName.icon]) - : undefined; + const FallbackIcon = input.fallbackIcon ?? FolderCodeIcon; if (!src || isProjectFaviconFallbackUrl(src)) { return ( ); } @@ -174,23 +94,70 @@ export function ProjectFavicon(input: { src={src} className={input.className} fallbackIcon={FallbackIcon} - fallbackEmoji={fallbackEmoji} - fallbackColorClassName={fallbackColorClassName} + fallbackProjectName={project.title} /> ); } function ProjectFaviconFallback({ className, - colorClassName, icon: Icon, emoji, + projectName, }: { readonly className?: string | undefined; - readonly colorClassName?: string | undefined; - readonly icon?: ComponentType<{ className?: string }> | undefined; + readonly icon: ComponentType<{ className?: string }>; readonly emoji?: string | undefined; + readonly projectName?: string | undefined; }) { + if (projectName && projectName.trim().length > 0) { + const identity = deriveProjectIdentity(projectName); + // Wrapped like the emoji and Lucide branches so the monogram sits where an + // favicon would. Menu items, buttons and the like pull every bare svg + // in with [&_svg]:-mx-0.5 to trim the padding stroke icons carry, and this + // tile has no such padding. + return ( + + ); + } + if (emoji) { return ( ; + return ; } function ProjectFaviconImage({ src, className, fallbackIcon: FallbackIcon, - fallbackEmoji, - fallbackColorClassName, + fallbackProjectName, }: { readonly src: string; readonly className?: string | undefined; - readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined; - readonly fallbackEmoji?: string | undefined; - readonly fallbackColorClassName?: string | undefined; + readonly fallbackIcon: ComponentType<{ className?: string }>; + readonly fallbackProjectName?: string | undefined; }) { const [displayedSrc, setDisplayedSrc] = useState(() => src.startsWith("data:image/") ? src : null, @@ -235,9 +199,8 @@ function ProjectFaviconImage({ {displayedSrc === null ? ( ) : null} {displayedSrc ? ( diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 304922909b0a..15cca412ba43 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -113,6 +113,7 @@ export default function ProjectScriptsControl({ command: fileScript.command, icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, + waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false, keybinding: null, previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e06176dfc48e..0a1ea308d128 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -44,6 +44,7 @@ import { sortProjectsForSidebar, sortScopedProjectsForSidebar, shouldCreateNewThreadInCurrentProject, + shouldNavigateAfterThreadPark, THREAD_JUMP_HINT_SHOW_DELAY_MS, type SidebarListItem, type SidebarListMarker, @@ -2500,3 +2501,44 @@ describe("resolveSidebarDropVerb", () => { expect(resolveSidebarDropVerb("active", "snoozed")).toBeNull(); }); }); + +describe("navigation after parking a thread", () => { + it.each([ + ["settle", "settled", null, "thread", true], + ["settle", "active", null, "thread", false], + ["settle", "settled", null, "other-thread", false], + ["snooze", null, "2099-01-01T00:00:00.000Z", "thread", true], + ["snooze", null, null, "thread", false], + ["snooze", null, "2026-09-12T09:00:00.000Z", "thread", false], + ["snooze", null, "2099-01-01T00:00:00.000Z", "thread", false, true], + ["snooze", null, "2099-01-01T00:00:00.000Z", "other-thread", false], + ] as const)( + "%s with state %s / %s on %s navigates: %s", + ( + action, + settledOverride, + snoozedUntil, + currentThreadKey, + expected, + hasPendingApprovals: boolean = false, + ) => { + expect( + shouldNavigateAfterThreadPark({ + threadKey: "thread", + currentThreadKey, + action, + now: "2026-09-12T10:00:00.000Z", + thread: { + settledOverride, + snoozedUntil, + snoozedAt: null, + session: null, + latestTurn: null, + hasPendingApprovals, + hasPendingUserInput: false, + }, + }), + ).toBe(expected); + }, + ); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index abb67e65a24e..27e47d131e0d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -9,6 +9,10 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/unstable/reactivity"; import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; +import { + effectiveSnoozed, + type ThreadSnoozeShell, +} from "@t3tools/client-runtime/state/thread-settled"; import { getThreadSortTimestamp, resolveSettledThreadTimestamp, @@ -21,6 +25,22 @@ import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; +export function shouldNavigateAfterThreadPark(input: { + readonly threadKey: string; + readonly currentThreadKey: string | null; + readonly action: "settle" | "snooze"; + readonly now: string; + readonly thread: (ThreadSnoozeShell & Pick) | null; +}): boolean { + return ( + input.threadKey === input.currentThreadKey && + input.thread !== null && + (input.action === "settle" + ? input.thread.settledOverride === "settled" + : effectiveSnoozed(input.thread, { now: input.now })) + ); +} + const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index aac6da996d47..ae2f459df6f2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { onAppCommand } from "../vim/commandBus"; import type { KeybindingCommand as AppKeybindingCommand } from "@t3tools/contracts"; +import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; @@ -170,6 +171,7 @@ import { resolveSidebarThreadStatus, searchSidebarThreads, shouldCreateNewThreadInCurrentProject, + shouldNavigateAfterThreadPark, shouldRecedeSidebarThread, resolveWorkingStartedAt, sidebarListItemId, @@ -441,7 +443,7 @@ function SidebarThreadTooltip({ function SnoozePopoverButton(props: { open: boolean; onOpenChange: (open: boolean) => void; - onSnooze: (preset: SnoozePreset) => void; + onSnooze: (preset: Pick) => void; timestampFormat: TimestampFormat; }) { const { open, onOpenChange, onSnooze, timestampFormat } = props; @@ -491,6 +493,19 @@ function SnoozePopoverButton(props: { ))} +
+ ); @@ -1001,7 +1016,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; - onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; + onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; @@ -1335,7 +1350,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [onUnpin, threadRef], ); const handleSnoozePreset = useCallback( - (preset: SnoozePreset) => { + (preset: Pick) => { onSnooze(threadRef, preset); }, [onSnooze, threadRef], @@ -3041,7 +3056,15 @@ export default function Sidebar() { } // Only move forward if the user is still on the settled thread — // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { + if ( + shouldNavigateAfterThreadPark({ + threadKey, + currentThreadKey: routeThreadKeyRef.current, + action: "settle", + now: new Date().toISOString(), + thread: readThreadShell(threadRef), + }) + ) { navigateAfterSettle?.(); } } finally { @@ -3570,7 +3593,17 @@ export default function Sidebar() { const settled = await run(settleThread(threadRef), "Failed to settle thread").finally( () => settlingThreadKeysRef.current.delete(activeKey), ); - if (settled && routeThreadKeyRef.current === activeKey) navigateAfterSettle?.(); + if ( + settled && + shouldNavigateAfterThreadPark({ + threadKey: activeKey, + currentThreadKey: routeThreadKeyRef.current, + action: "settle", + now: new Date().toISOString(), + thread: readThreadShell(threadRef), + }) + ) + navigateAfterSettle?.(); return; } case "move-active": @@ -3646,7 +3679,7 @@ export default function Sidebar() { const performSnooze = useCallback( async ( threadRef: ScopedThreadRef, - preset: SnoozePreset, + preset: Pick, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { const threadKey = scopedThreadKey(threadRef); @@ -3667,7 +3700,15 @@ export default function Sidebar() { } // Only move forward if the user is still on the snoozed thread — // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { + if ( + shouldNavigateAfterThreadPark({ + threadKey, + currentThreadKey: routeThreadKeyRef.current, + action: "snooze", + now: new Date().toISOString(), + thread: readThreadShell(threadRef), + }) + ) { navigateAfterSnooze?.(); } return { status: "success" } as const; @@ -3680,7 +3721,7 @@ export default function Sidebar() { const attemptSnooze = useCallback( ( threadRef: ScopedThreadRef, - preset: SnoozePreset, + preset: Pick, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { void (async () => { @@ -3776,10 +3817,13 @@ export default function Sidebar() { { id: "snooze", label: `Snooze (${count})`, - children: snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}`, - label: `${preset.label} (${preset.whenLabel})`, - })), + children: [ + ...snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + { id: "snooze:custom", label: "Custom…", separatorBefore: true }, + ], }, ] : []), @@ -3792,9 +3836,10 @@ export default function Sidebar() { ); if (clicked._tag === "Failure") return; if (clicked.value?.startsWith("snooze:")) { - const preset = snoozePresets.find( - (candidate) => `snooze:${candidate.id}` === clicked.value, - ); + const preset = + clicked.value === "snooze:custom" + ? await requestCustomSnooze() + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); if (preset) { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. @@ -4021,9 +4066,10 @@ export default function Sidebar() { ); if (clicked._tag === "Failure") return; if (clicked.value?.startsWith("snooze:")) { - const preset = snoozePresets.find( - (candidate) => `snooze:${candidate.id}` === clicked.value, - ); + const preset = + clicked.value === "snooze:custom" + ? await requestCustomSnooze() + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); if (preset) attemptSnooze(threadRef, preset); return; } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e3ae13911036..f7d3ac540ccc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -15,6 +15,7 @@ import { } from "../../questionAttachments"; import type { ApprovalRequestId, + KeybindingCommand, AssistantCitation, ChatFileAttachment, EnvironmentId, @@ -62,7 +63,7 @@ import { useState, useSyncExternalStore, } from "react"; -import { createPortal } from "react-dom"; +import { createPortal, flushSync } from "react-dom"; import { clampCollapsedComposerCursor, type ComposerSubmissionIntent, @@ -89,6 +90,7 @@ import { } from "./composerMentionDrag"; import { composerFloatingLayerProps, + useComposerMenuProps, isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, @@ -242,6 +244,7 @@ import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; +import { ComposerImageThumbnail } from "./ComposerImageThumbnail"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; @@ -1036,6 +1039,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const size = props.size ?? "sm"; + const composerFloatingLayerProps = useComposerMenuProps(); const [open, setOpen] = useComposerMenuState(props.hidden); const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; @@ -1102,6 +1106,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -1197,7 +1201,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} - showSendWhileRunning={props.showSendWhileRunning ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} @@ -1230,6 +1233,7 @@ export interface ChatComposerHandle { ) => boolean; openModelPicker: () => void; toggleModelPicker: () => void; + openControl: (command: KeybindingCommand) => void; isModelPickerOpen: () => boolean; compactContext: () => void; readSnapshot: () => { @@ -4673,7 +4677,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }} > {image.previewUrl ? ( - + + } + /> ) : ( { + if (composerBlurFrameRef.current !== null) { + window.cancelAnimationFrame(composerBlurFrameRef.current); + composerBlurFrameRef.current = null; + } + flushSync(() => { + setIsComposerScrollCollapsed(false); + setIsComposerFocused(true); + }); + const shell = composerFormRef.current?.closest('[data-slot="composer-shell"]'); + const trigger = Array.from( + shell?.querySelectorAll( + `button[data-composer-shortcut~="${command}"]:not(:disabled)`, + ) ?? [], + ).find( + (element) => + !element.closest("[inert]") && element.checkVisibility({ visibilityProperty: true }), + ); + if (!trigger) return; + trigger.focus({ preventScroll: true }); + trigger.click(); + }, compactContext: compactThreadContext, isModelPickerOpen: () => isComposerModelPickerOpen, readSnapshot: () => { @@ -6287,10 +6325,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onExpandImage(preview); }} > - {image.name} + {image.name} + + } /> ) : ( @@ -6802,7 +6845,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting} - showSendWhileRunning={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 039f12d40873..1969e2e9de36 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -10,7 +10,7 @@ import { MenuTrigger, } from "../ui/menu"; import { ComposerControl, ComposerControlIcon } from "./ComposerControl"; -import { composerFloatingLayerProps } from "./composerEventScope"; +import { useComposerMenuProps } from "./composerEventScope"; import { useComposerMenuState } from "./useComposerMenuState"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { @@ -28,6 +28,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { + const composerFloatingLayerProps = useComposerMenuProps(); const size = props.size ?? "sm"; const [open, setOpen] = useComposerMenuState(props.hidden); @@ -40,6 +41,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls variant="ghost" className={size === "xs" ? "shrink-0" : "shrink-0 px-2"} aria-label="More composer controls" + data-composer-shortcut={ + props.traitsMenuContent ? "composer.mode composer.effort" : "composer.mode" + } /> } > diff --git a/apps/web/src/components/chat/ComposerImageThumbnail.tsx b/apps/web/src/components/chat/ComposerImageThumbnail.tsx new file mode 100644 index 000000000000..c803d5693009 --- /dev/null +++ b/apps/web/src/components/chat/ComposerImageThumbnail.tsx @@ -0,0 +1,29 @@ +import { memo, useEffect, useState, type ReactNode } from "react"; + +import { createComposerImageThumbnail } from "../../lib/imageCompression"; + +/** Keep full-resolution image decoding out of composer rerenders. */ +export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({ + file, + alt, + className, + fallback, +}: { + file: File; + alt: string; + className: string; + fallback: ReactNode; +}) { + const [preview, setPreview] = useState<{ file: File; src: string | null } | null>(null); + useEffect(() => { + let active = true; + void createComposerImageThumbnail(file).then((src) => { + if (active) setPreview({ file, src }); + }); + return () => { + active = false; + }; + }, [file]); + const src = preview?.file === file ? preview.src : null; + return src ? {alt} : fallback; +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 45ef93568cf6..b2f017bcf335 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -44,7 +44,7 @@ function renderPendingActions(isRunning: boolean) { ); } -function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) { +function renderRunningActions(hasSendableContent: boolean) { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { compact: true, @@ -58,7 +58,6 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: isEnvironmentUnavailable: false, isPreparingWorktree: false, hasSendableContent, - showSendWhileRunning, onPreviousPendingQuestion: () => {}, onInterrupt: () => {}, onImplementPlanInNewThread: () => {}, @@ -125,25 +124,18 @@ describe("ComposerPrimaryActions", () => { expect(markup).not.toContain("stage-nightly"); }); - it("only renders stop while running when Enter-to-send is available", () => { - const markup = renderRunningActions(false, true); + it("renders a queue action alongside stop while running with a sendable draft", () => { + const markup = renderRunningActions(true); expect(markup).toContain('aria-label="Stop generation"'); - expect(markup).not.toContain('aria-label="Send message"'); - }); - - it("renders send alongside stop while running when Enter-to-send is unavailable", () => { - const markup = renderRunningActions(true, true); - - expect(markup).toContain('aria-label="Stop generation"'); - expect(markup).toContain('aria-label="Send message"'); + expect(markup).toContain('aria-label="Queue message"'); expect(markup).toContain('type="submit"'); }); it("keeps stop as the only action while running with an empty composer", () => { - const markup = renderRunningActions(true, false); + const markup = renderRunningActions(false); expect(markup).toContain('aria-label="Stop generation"'); - expect(markup).not.toContain('aria-label="Send message"'); + expect(markup).not.toContain('aria-label="Queue message"'); }); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 91c54b75ed03..c71c8fd234a2 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -29,9 +29,6 @@ interface ComposerPrimaryActionsProps { isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; - /** Enter-to-send is disabled on mobile viewports, where stop would otherwise - * be the only primary action and a running turn could not be steered. */ - showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -72,7 +69,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, - showSendWhileRunning = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, @@ -93,7 +89,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ "flex cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none", insidePendingAction ? "size-8 sm:size-7" - : showSendWhileRunning && hasSendableContent + : hasSendableContent ? "size-9 sm:size-8" : "size-8 sm:h-8 sm:w-8", )} @@ -247,7 +243,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ? "Preparing worktree" : isSendBusy ? "Sending" - : "Send message" + : isRunning + ? "Queue message" + : "Send message" } > {stageBackdropVariant ? ( @@ -275,10 +273,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ return sendButton; } + // While a turn runs, a sendable draft queues for the next tool boundary, so + // the send button stays next to Stop on every viewport. return ( <> {renderStopGenerationButton(false)} - {showSendWhileRunning && hasSendableContent ? sendButton : null} + {hasSendableContent ? sendButton : null} ); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 59b4a0c9856b..24078b5f0af0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1092,6 +1092,40 @@ describe("resolveAssistantMessageCopyState", () => { }); describe("deriveMessagesTimelineRows", () => { + it("appends queued messages after the live rows, marking the oldest as next", () => { + const queuedMessage = (id: string, prompt: string) => ({ + id, + prompt, + images: [], + files: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + submissionIntent: "foreground" as const, + queuedAfterToolActivityId: null, + createdAt: "2026-01-01T00:00:01Z", + }); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + queuedMessages: [queuedMessage("q1", "first"), queuedMessage("q2", "second")], + }); + + expect(rows.map((row) => row.kind)).toEqual([ + "working", + "thinking", + "queued-message", + "queued-message", + ]); + expect(rows.slice(2)).toMatchObject([ + { id: "queued-message:q1", isNext: true, queuedMessage: { prompt: "first" } }, + { id: "queued-message:q2", isNext: false, queuedMessage: { prompt: "second" } }, + ]); + }); + it("shows the worktree setup card instead of the working placeholder", () => { const snapshot: WorktreeSetupSnapshot = { threadId: ThreadId.make("thread-setup"), @@ -1148,17 +1182,18 @@ describe("deriveMessagesTimelineRows", () => { id: WORKTREE_SETUP_ROW_ID, createdAt: "2026-01-01T00:00:00Z", snapshot, + embedded: false, }, ]); - // Once the agent has replied the finished card stays under the send. + // A failed setup never handed off, so the card stays under the send. const withMessages = deriveMessagesTimelineRows({ timelineEntries: [userEntry, assistantEntry], isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", turnDiffSummaries: [], supportsConversationRollback: false, - worktreeSetup: { ...snapshot, phase: "done" }, + worktreeSetup: { ...snapshot, phase: "failed" }, }); expect(withMessages.map((row) => row.kind)).toEqual([ "message", @@ -1166,6 +1201,73 @@ describe("deriveMessagesTimelineRows", () => { "working", "message", ]); + + // Once the agent stage is done the setup script may still be running in + // the background: the turn owns the header and the script row follows it. + const stage = (id: "agent" | "setup-script", status: "done" | "running") => + ({ + id, + status, + startedAt: "2026-01-01T00:00:10Z", + endedAt: status === "done" ? "2026-01-01T00:00:11Z" : null, + percent: null, + detail: null, + tail: [], + }) as const; + const asyncSnapshot: WorktreeSetupSnapshot = { + ...snapshot, + stages: [stage("setup-script", "running"), stage("agent", "done")], + }; + const liveTurn = { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:11Z", + completedAt: null, + } as const; + const asyncRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + latestTurn: liveTurn, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: asyncSnapshot, + }); + expect(asyncRows.map((row) => row.kind)).toEqual([ + "message", + "working", + "worktree-setup", + "thinking", + ]); + expect(asyncRows[2]).toMatchObject({ kind: "worktree-setup", embedded: true }); + + // Dispatched but not yet visible as a turn: the full card stays put so + // nothing collapses during the handoff. + const handoffRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: asyncSnapshot, + }); + expect(handoffRows.map((row) => row.kind)).toEqual(["message", "worktree-setup"]); + expect(handoffRows[1]).toMatchObject({ kind: "worktree-setup", embedded: false }); + + // A script that already finished has nothing left to show once the turn is live. + const finishedRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + latestTurn: liveTurn, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: { + ...asyncSnapshot, + stages: [stage("setup-script", "done"), stage("agent", "done")], + }, + }); + expect(finishedRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]); }); it("keeps context compaction visible outside folded work", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 3d7b1e12284e..983e49dfaa87 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -28,6 +28,7 @@ import { type WorkLogEntry, } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; +import type { QueuedComposerMessage } from "../../queuedMessageStore"; import { type MessageId, type OrchestrationLatestTurn, @@ -400,6 +401,16 @@ export type MessagesTimelineRow = id: string; createdAt: string | null; snapshot: WorktreeSetupSnapshot; + /** The agent already started; render only the script row under the turn header. */ + embedded: boolean; + } + | { + kind: "queued-message"; + id: string; + createdAt: string; + queuedMessage: QueuedComposerMessage; + /** Oldest queued message, the one the next boundary sends. */ + isNext: boolean; }; export interface StableMessagesTimelineRowsState { @@ -870,6 +881,8 @@ export function deriveMessagesTimelineRows(input: { liveAgentTaskIds?: ReadonlySet | undefined; /** Live bootstrap progress. Renders a stage card under the first user message. */ worktreeSetup?: WorktreeSetupSnapshot | null; + /** Messages sent during the running turn, rendered after the live rows. */ + queuedMessages?: ReadonlyArray; }): MessagesTimelineRow[] { const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { @@ -1260,15 +1273,23 @@ export function deriveMessagesTimelineRows(input: { }); } - // The setup card takes the place of the working and thinking placeholders - // while a worktree is being prepared. It stays after the setup settles so a - // failure and its actions remain visible until the thread state moves on. - if (input.worktreeSetup) { + // Until the agent's turn is live, the setup card takes the place of the + // working and thinking placeholders. It stays after a failed or cancelled + // setup so the outcome and its actions remain visible until the thread + // state moves on. "Live" means the turn is in the timeline, not just that + // the server dispatched it: the card must not collapse in the gap between. + const setupHandedOff = + input.worktreeSetup !== null && + input.worktreeSetup !== undefined && + worktreeSetupAgentStarted(input.worktreeSetup) && + input.latestTurn?.startedAt != null; + if (input.worktreeSetup && !setupHandedOff) { const setupRow = { kind: "worktree-setup", id: WORKTREE_SETUP_ROW_ID, createdAt: input.worktreeSetup.startedAt, snapshot: input.worktreeSetup, + embedded: false, } as const; // Sit directly under the first user message: a finished snapshot can // outlive the first assistant reply, and it belongs to the send, not the @@ -1287,6 +1308,31 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } + // An async setup script outlives the handoff. The turn owns the header, so + // the script's row sits first under it, ahead of the agent's own work. A + // script that already finished (or never ran) has nothing left to show. + const setupScriptStage = input.worktreeSetup?.stages.find((stage) => stage.id === "setup-script"); + if ( + input.worktreeSetup && + setupHandedOff && + (setupScriptStage?.status === "running" || setupScriptStage?.status === "failed") + ) { + const setupRow = { + kind: "worktree-setup", + id: WORKTREE_SETUP_ROW_ID, + createdAt: input.worktreeSetup.startedAt, + snapshot: input.worktreeSetup, + embedded: true, + } as const; + const workingRowIndex = nextRows.findIndex((row) => row.kind === "working"); + if (workingRowIndex >= 0) { + nextRows.splice(workingRowIndex + 1, 0, setupRow); + } else { + // The turn already finished (or has not been dispatched yet): the row + // trails the reply so a still-running script stays visible after it. + nextRows.push(setupRow); + } + } if (input.isWorking && (!hasActivityRow || latestToolFailed)) { nextRows.push({ kind: "thinking", @@ -1294,12 +1340,26 @@ export function deriveMessagesTimelineRows(input: { createdAt: input.activeTurnStartedAt, }); } - - return attachTrailingToolGroupsToAssistant(nextRows); + const rows = attachTrailingToolGroupsToAssistant(nextRows); + input.queuedMessages?.forEach((queuedMessage, index) => { + rows.push({ + kind: "queued-message", + id: `queued-message:${queuedMessage.id}`, + createdAt: queuedMessage.createdAt, + queuedMessage, + isNext: index === 0, + }); + }); + return rows; } export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; +/** True once the bootstrap handed off to the agent (async setup script may still run). */ +export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { + return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); +} + type MessagesTimelineRowsInput = Parameters[0]; export interface MessagesTimelineRowsProjection { @@ -1428,6 +1488,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "proposed-plan": return a.proposedPlan === (b as typeof a).proposedPlan; + case "queued-message": { + const bq = b as typeof a; + return a.queuedMessage === bq.queuedMessage && a.isNext === bq.isNext; + } + case "work": { const bw = b as typeof a; return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 8f982064869b..0987b852e716 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -791,7 +791,6 @@ describe("MessagesTimeline", () => { expect(markup).toContain(" {}; +const EMPTY_QUEUED_MESSAGES: ReadonlyArray = []; +const NOOP_QUEUED_MESSAGE_ACTION = (_id: string) => {}; const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; @@ -133,6 +136,7 @@ import type { KnownComposerContextRecord, } from "@t3tools/contracts"; import { Button } from "../ui/button"; +import type { QueuedComposerMessage } from "../../queuedMessageStore"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; @@ -282,6 +286,8 @@ interface TimelineRowSharedState { onCancelWorktreeSetup: (() => void) | null; onWorktreeSetupWorkLocally: (() => void) | null; onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; + onSteerQueuedMessage: (id: string) => void; + onRemoveQueuedMessage: (id: string) => void; } interface TimelineRowActivityState { @@ -436,6 +442,10 @@ interface MessagesTimelineProps { topFadeEnabled?: boolean; /** Non-null when older turns exist beyond the loaded window. */ loadEarlier?: CitationHistoryPage | null; + /** Messages sent during the running turn. They render as ghost bubbles after the live rows. */ + queuedMessages?: ReadonlyArray; + onSteerQueuedMessage?: (id: string) => void; + onRemoveQueuedMessage?: (id: string) => void; } // --------------------------------------------------------------------------- @@ -490,6 +500,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hideEmptyPlaceholder = false, topFadeEnabled = false, loadEarlier = null, + queuedMessages = EMPTY_QUEUED_MESSAGES, + onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, + onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, }: MessagesTimelineProps) { const vimEnabled = useClientSettings((settings) => settings.vim.enabled); const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); @@ -714,6 +727,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ supportsConversationRollback, liveAgentTaskIds, worktreeSetup, + queuedMessages, }, previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -736,6 +750,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ supportsConversationRollback, liveAgentTaskIds, worktreeSetup, + queuedMessages, ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); @@ -931,6 +946,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, + onSteerQueuedMessage, + onRemoveQueuedMessage, }), [ readyCitationRequest, @@ -961,6 +978,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onCancelWorktreeSetup, onWorktreeSetupWorkLocally, onOpenWorktreeSetupTerminal, + onSteerQueuedMessage, + onRemoveQueuedMessage, ], ); const activityState = useMemo( @@ -1470,6 +1489,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "working" ? : null} {row.kind === "thinking" ? : null} {row.kind === "worktree-setup" ? : null} + {row.kind === "queued-message" ? : null}
); }); @@ -1489,13 +1509,113 @@ function WorktreeSetupTimelineRow({ return ( ); } +/** A message waiting for the running turn: a dashed user bubble with icon actions inside it. */ +function QueuedMessageTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + const { queuedMessage } = row; + const attachmentCount = queuedMessage.images.length + queuedMessage.files.length; + const contextCount = + queuedMessage.terminalContexts.length + + queuedMessage.previewAnnotations.length + + queuedMessage.reviewComments.length; + const text = queuedMessage.prompt.trim(); + const statusLabel = queuedMessage.holdUntilUserAction + ? "Waits for Send now" + : row.isNext + ? "Sends after the next tool call or when the turn ends" + : "Sends after the messages above it"; + return ( +
+
+ {text.length > 0 ? ( +
{text}
+ ) : null} + {attachmentCount > 0 || contextCount > 0 ? ( +
0 && "mt-1.5")}> + {[ + attachmentCount > 0 + ? `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}` + : null, + contextCount > 0 + ? `${contextCount} context item${contextCount === 1 ? "" : "s"}` + : null, + ] + .filter(Boolean) + .join(", ")} +
+ ) : null} +
+ + } + aria-label={`Queued. ${statusLabel}.`} + > + + Queued + + {statusLabel} + +
+ + event.preventDefault()} + onClick={() => ctx.onSteerQueuedMessage(queuedMessage.id)} + aria-label="Send now" + /> + } + > + + + Send now + + + event.preventDefault()} + onClick={() => ctx.onRemoveQueuedMessage(queuedMessage.id)} + aria-label="Cancel and return to the composer" + /> + } + > + + + Cancel and return to the composer + +
+
+
+
+ ); +} + function ContextCompactionTimelineRow({ row, }: { @@ -1547,6 +1667,10 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { } label={file.name} preload="visible" + onOpen={() => { + const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file); + if (preview) ctx.onImageExpand(preview); + }} className="block aspect-[4/3] w-full" videoClassName="aspect-auto size-full rounded-lg border border-border/80" stateClassName="aspect-auto min-h-full rounded-lg border border-border/80 bg-black text-white" diff --git a/apps/web/src/components/chat/ModelPickerContent.test.ts b/apps/web/src/components/chat/ModelPickerContent.test.ts index 5b9cfd0ff5f2..50db51bfa068 100644 --- a/apps/web/src/components/chat/ModelPickerContent.test.ts +++ b/apps/web/src/components/chat/ModelPickerContent.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveProviderInstanceEntries } from "../../providerInstances"; import { + adjacentModelPickerProvider, resolveModelPickerSelectedModel, shouldIncludeModelPickerOption, shouldOfferModelPickerSetup, @@ -213,3 +214,74 @@ describe("shouldOfferModelPickerSetup", () => { ).toBe(true); }); }); + +describe("adjacentModelPickerProvider", () => { + const codex = entry("ready", "codex"); + const claude = entry("ready", "claudeAgent"); + const unavailable = entry("error"); + const input = { + entries: [codex, unavailable, claude], + disabledInstanceIds: undefined, + selectableUnavailableInstanceIds: undefined, + }; + + it("wraps through favorites and ready instances, skipping unavailable providers", () => { + expect( + adjacentModelPickerProvider({ ...input, selectedInstanceId: codex.instanceId, direction: 1 }), + ).toBe(claude.instanceId); + expect( + adjacentModelPickerProvider({ ...input, selectedInstanceId: "favorites", direction: -1 }), + ).toBe(claude.instanceId); + expect( + adjacentModelPickerProvider({ + ...input, + selectedInstanceId: claude.instanceId, + direction: 1, + }), + ).toBe("favorites"); + }); + + it("keeps thread locks and the selected unavailable catalog", () => { + expect( + adjacentModelPickerProvider({ + ...input, + disabledInstanceIds: new Set([claude.instanceId]), + selectedInstanceId: codex.instanceId, + direction: 1, + }), + ).toBe("favorites"); + expect( + adjacentModelPickerProvider({ + ...input, + selectableUnavailableInstanceIds: new Set([unavailable.instanceId]), + selectedInstanceId: codex.instanceId, + direction: 1, + }), + ).toBe(unavailable.instanceId); + }); + + it("handles an empty catalog and a removed selection in either direction", () => { + expect( + adjacentModelPickerProvider({ + ...input, + entries: [], + selectedInstanceId: codex.instanceId, + direction: -1, + }), + ).toBe("favorites"); + expect( + adjacentModelPickerProvider({ + ...input, + selectedInstanceId: unavailable.instanceId, + direction: 1, + }), + ).toBe("favorites"); + expect( + adjacentModelPickerProvider({ + ...input, + selectedInstanceId: unavailable.instanceId, + direction: -1, + }), + ).toBe(claude.instanceId); + }); +}); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 63dea7844c46..5ee3743a33ee 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -113,6 +113,34 @@ export function shouldOfferModelPickerSetup( ); } +export function adjacentModelPickerProvider(input: { + entries: ReadonlyArray; + selectedInstanceId: ProviderInstanceId | "favorites"; + direction: 1 | -1; + disabledInstanceIds: ReadonlySet | undefined; + selectableUnavailableInstanceIds: ReadonlySet | undefined; +}) { + const providers: Array = [ + "favorites", + ...input.entries + .filter( + (entry) => + !input.disabledInstanceIds?.has(entry.instanceId) && + (isProviderInstancePickerReady(entry) || + input.selectableUnavailableInstanceIds?.has(entry.instanceId)), + ) + .map((entry) => entry.instanceId), + ]; + const index = providers.indexOf(input.selectedInstanceId); + return providers[ + index < 0 + ? input.direction === 1 + ? 0 + : providers.length - 1 + : (index + input.direction + providers.length) % providers.length + ]!; +} + const EMPTY_MODEL_JUMP_LABELS = new Map(); function ModelListSeparator() { @@ -687,6 +715,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { platform: navigator.platform, context: modelJumpShortcutContext, }); + if (command === "modelPicker.previousProvider" || command === "modelPicker.nextProvider") { + event.preventDefault(); + event.stopPropagation(); + const next = adjacentModelPickerProvider({ + entries: sidebarInstanceEntries, + selectedInstanceId, + direction: command === "modelPicker.nextProvider" ? 1 : -1, + disabledInstanceIds: lockedDisabledInstanceIds, + selectableUnavailableInstanceIds, + }); + setSearchQuery(""); + handleSelectInstance(next); + return; + } const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); if (jumpIndex === null) { return; @@ -710,7 +752,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return () => { window.removeEventListener("keydown", onWindowKeyDown, true); }; - }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); + }, [ + handleModelSelect, + handleSelectInstance, + keybindings, + lockedDisabledInstanceIds, + modelJumpModelKeys, + modelJumpShortcutContext, + selectableUnavailableInstanceIds, + selectedInstanceId, + sidebarInstanceEntries, + ]); useLayoutEffect(() => { setShowTopScrollFade(false); @@ -737,6 +789,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { setSearchQuery(e.target.value)} onKeyDown={(e) => { + if ( + showSidebar && + !e.altKey && + !e.ctrlKey && + !e.metaKey && + ((e.key === "ArrowLeft" && !e.shiftKey && searchQuery.length === 0) || + (e.key === "Tab" && e.shiftKey)) + ) { + const sidebar = e.currentTarget + .closest("[data-model-picker-content]") + ?.querySelector("[data-model-picker-sidebar]"); + const button = + sidebar?.querySelector( + 'button[aria-pressed="true"]:not(:disabled)', + ) ?? sidebar?.querySelector("button:not(:disabled)"); + if (button) { + e.preventDefault(); + e.stopPropagation(); + button.focus(); + return; + } + } if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index dd53270268ba..30f9e3ce809b 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -1,3 +1,4 @@ +import { Toolbar } from "@base-ui/react/toolbar"; import { type ProviderInstanceId } from "@t3tools/contracts"; import { memo, useLayoutEffect, useRef, useState } from "react"; import { SparklesIcon, StarIcon } from "lucide-react"; @@ -43,6 +44,7 @@ const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { selectedInstanceId: ProviderInstanceId | "favorites"; onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void; + onFocusSearch: () => void; /** * Instance entries to render as rail buttons. Each entry becomes one icon * keyed by `instanceId`, so the default built-in Codex and a user-authored @@ -87,7 +89,20 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); return ( -
+ { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + if (event.key === "ArrowRight") { + event.preventDefault(); + props.onFocusSearch(); + return; + } + }} + >
{selectedIndicatorTop !== null ? ( @@ -107,16 +122,17 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { handleSelect("favorites")} type="button" aria-label="Favorites" + aria-pressed={props.selectedInstanceId === "favorites"} > - + } /> (current === entry.instanceId ? null : current)) } disabled={isDisabled} + focusableWhenDisabled={!isDisabled} + aria-pressed={isSelected} type="button" aria-label={ isUnavailable || isContextDisabled @@ -203,7 +221,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ) : null} - + ); const trigger = isDisabled ? ( @@ -234,6 +252,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { })}
-
+ ); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 6f777399451d..bc8e7b48ce96 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -24,7 +24,7 @@ import { ComposerControlChevron, type ComposerControlSize, } from "./ComposerControl"; -import { composerFloatingLayerProps } from "./composerEventScope"; +import { useComposerMenuProps } from "./composerEventScope"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /** @@ -56,6 +56,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { + const composerFloatingLayerProps = useComposerMenuProps(); const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; const size = props.size ?? "sm"; diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 47ce59efde52..da293c38ddc9 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -38,7 +38,7 @@ import { ComposerControlIcon, type ComposerControlSize, } from "./ComposerControl"; -import { composerFloatingLayerProps } from "./composerEventScope"; +import { useComposerMenuProps } from "./composerEventScope"; import { useComposerMenuState } from "./useComposerMenuState"; type ProviderOptions = ReadonlyArray; @@ -557,6 +557,7 @@ export const TraitsPicker = memo(function TraitsPicker({ size?: ComposerControlSize; hidden?: boolean; }) { + const composerFloatingLayerProps = useComposerMenuProps(); const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden); const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ @@ -618,6 +619,7 @@ export const TraitsPicker = memo(function TraitsPicker({ + + {children} + + + ); +} + +function headerLabel(snapshot: WorktreeSetupSnapshot): string { + switch (snapshot.phase) { + case "running": + return "Setting up worktree…"; + case "done": + return snapshot.stages.some((stage) => stage.status === "failed") + ? "Worktree ready, setup script failed" + : "Worktree ready"; + case "failed": + return "Worktree setup failed"; + case "cancelled": + return "Worktree setup cancelled"; + } +} + +/** + * Occupies the same slot, with the same metrics, as the "Working for" header + * so the handoff to the agent's turn only swaps the text. + */ +function SetupHeaderRow({ + snapshot, + totalElapsed, +}: { + snapshot: WorktreeSetupSnapshot; + totalElapsed: number | null; +}) { + const running = snapshot.phase === "running"; + const failed = snapshot.phase === "failed"; + const finishedWithFailedStage = + snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed"); + const text = headerLabel(snapshot); + const tone = failed + ? "text-destructive-foreground" + : finishedWithFailedStage + ? "text-warning-foreground" + : "text-muted-foreground"; + return ( +
+
+ + {text} + {running ? {text} : null} + + {totalElapsed !== null ? ( + + {formatDuration(totalElapsed)} + + ) : null} +
+
+ ); +} + +/** One stage, rendered like a live work entry row. */ function StageRow({ stage, nowMs, @@ -100,44 +175,49 @@ function StageRow({ const elapsed = stageElapsedMs(stage, nowMs); const label = stage.id === "setup-script" && scriptName ? scriptName : worktreeSetupStageLabel(stage.id); - const showBar = stage.id === "checkout" && stage.status === "running" && stage.percent !== null; + const running = stage.status === "running"; const trailing = stage.status === "pending" ? null : stage.status === "skipped" ? (stage.detail ?? "skipped") - : stage.detail; - + : stage.id === "checkout" && running && stage.percent !== null + ? `${stage.percent}%` + : stage.detail; return (
- + {label} - - {showBar ? ( - <> - - + {trailing ? ( + + {trailing} + + ) : null} + {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? ( + + {formatDuration(elapsed)} + + ) : null} + {running ? ( + + + + - {stage.percent}% - - ) : null} - {!showBar && trailing ? {trailing} : null} - {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? ( - {formatDuration(elapsed)} - ) : null} - + {label} + + + ) : null}
); } @@ -158,19 +238,35 @@ function OutputTail({ lines, failed }: { lines: ReadonlyArray; failed: b ); } -function headerLabel(snapshot: WorktreeSetupSnapshot): string { - switch (snapshot.phase) { - case "running": - return "Creating worktree"; - case "done": - return snapshot.stages.some((stage) => stage.status === "failed") - ? "Worktree ready, setup script failed" - : "Worktree ready"; - case "failed": - return "Worktree setup failed"; - case "cancelled": - return "Worktree setup cancelled"; - } +function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { + return ( +
+ {snapshot.branch ? ( + <> +
Branch
+
{snapshot.branch}
+ + ) : null} + {snapshot.baseRef ? ( + <> +
Base
+
{snapshot.baseRef}
+ + ) : null} + {snapshot.worktreePath ? ( + <> +
Path
+
{snapshot.worktreePath}
+ + ) : null} + {snapshot.setupScript ? ( + <> +
Setup
+
{snapshot.setupScript.command}
+ + ) : null} +
+ ); } export function WorktreeSetupCard({ @@ -178,7 +274,14 @@ export function WorktreeSetupCard({ onCancel, onWorkLocally, onOpenTerminal, -}: WorktreeSetupCardProps) { + embedded = false, +}: WorktreeSetupCardProps & { + /** + * The agent already started (async setup script), so the turn owns the + * "Working for" header and only the script's row sits among the worklog. + */ + embedded?: boolean; +}) { const running = snapshot.phase === "running"; const nowMs = useNowWhile(running); const [detailsOpen, setDetailsOpen] = useState(false); @@ -188,79 +291,35 @@ export function WorktreeSetupCard({ return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null; })(); const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); - const failed = snapshot.phase === "failed"; - const finishedWithFailedStage = - snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed"); - const headerClassName = failed - ? "text-destructive-foreground" - : finishedWithFailedStage - ? "text-warning-foreground" - : snapshot.phase === "cancelled" - ? "text-muted-foreground" - : "text-secondary-label"; + const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending"; + const stages = embedded + ? snapshot.stages.filter((stage) => stage.id === "setup-script") + : snapshot.stages; return ( -
-
- - - - {headerLabel(snapshot)} - {totalElapsed !== null ? ( - - {formatDuration(totalElapsed)} - - ) : null} +
+ {embedded ? null : } +
+ {stages.map((stage) => ( +
+ + {stage.id === "setup-script" && + (stage.status === "running" || stage.status === "failed") ? ( + + ) : null} +
+ ))}
- {snapshot.stages.map((stage) => ( -
- - {stage.id === "setup-script" && - (stage.status === "running" || stage.status === "failed") ? ( - - ) : null} -
- ))} - - {failed && snapshot.error ? ( -

{snapshot.error}

+ {snapshot.phase === "failed" && snapshot.error ? ( +

{snapshot.error}

) : null} - {detailsOpen ? ( -
- {snapshot.branch ? ( - <> -
Branch
-
{snapshot.branch}
- - ) : null} - {snapshot.baseRef ? ( - <> -
Base
-
{snapshot.baseRef}
- - ) : null} - {snapshot.worktreePath ? ( - <> -
Path
-
{snapshot.worktreePath}
- - ) : null} - {snapshot.setupScript ? ( - <> -
Setup
-
{snapshot.setupScript.command}
- - ) : null} -
- ) : null} + {detailsOpen ? : null} -
+ {/* Indented so the first label lines up with the stage labels: the icon + column, minus the xs button's own horizontal padding. */} +
- - {onOpenTerminal && setupStage && setupStage.status !== "pending" ? ( - ) : null} {onWorkLocally ? ( - ) : null} {onCancel && running ? ( - diff --git a/apps/web/src/components/chat/composerEventScope.test.ts b/apps/web/src/components/chat/composerEventScope.test.ts index 365c5304aa2d..f40c9a68e6ee 100644 --- a/apps/web/src/components/chat/composerEventScope.test.ts +++ b/apps/web/src/components/chat/composerEventScope.test.ts @@ -1,9 +1,13 @@ +import { act, createElement, useLayoutEffect } from "react"; +import { create } from "react-test-renderer"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext"; import { isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, + useComposerMenuProps, } from "./composerEventScope"; class FakeElement { @@ -21,6 +25,42 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe("composer menu focus", () => { + it.each([ + ["an open menu", '[data-chat-composer-floating-layer="true"]', true], + ["an unmounted menu", null, true], + ["another control", "input", false], + ])("closes while focus is on %s", async (_label, selector, shouldFocusComposer) => { + const body = new FakeElement(null); + const editor = new FakeElement(null); + const activeElement = selector === null ? body : new FakeElement(selector); + const document = { body, activeElement }; + const composerRef = { + current: { focusAtEnd: () => (document.activeElement = editor) }, + } as unknown as ComposerHandleRef; + vi.stubGlobal("Element", FakeElement); + vi.stubGlobal("document", document); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let menuProps: ReturnType | undefined; + function Probe() { + const props = useComposerMenuProps(); + useLayoutEffect(() => { + menuProps = props; + }, [props]); + return null; + } + const renderer = await act(() => + create(createElement(ComposerHandleContext, { value: composerRef }, createElement(Probe))), + ); + try { + expect(menuProps?.finalFocus?.()).toBe(false); + expect(document.activeElement).toBe(shouldFocusComposer ? editor : activeElement); + } finally { + await act(() => renderer.unmount()); + } + }); +}); + describe("composer event scopes", () => { it("recognizes events from the portaled resting controls", () => { vi.stubGlobal("Element", FakeElement); diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts index 88e24fd89422..391c468d0d03 100644 --- a/apps/web/src/components/chat/composerEventScope.ts +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -1,3 +1,5 @@ +import { useComposerHandleContext } from "../../composerHandleContext"; + const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-composer-drawer-layer="true"]', '[data-chat-composer-floating-layer="true"]', @@ -7,6 +9,24 @@ export const composerFloatingLayerProps = { "data-chat-composer-floating-layer": "true", } as const; +export function useComposerMenuProps() { + const composerRef = useComposerHandleContext(); + + return { + ...composerFloatingLayerProps, + finalFocus: composerRef + ? () => { + const activeElement = document.activeElement; + if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) { + return false; + } + composerRef.current?.focusAtEnd(); + return false; + } + : undefined, + }; +} + export function isInsideComposerFloatingLayer(target: EventTarget | null): boolean { return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; } diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index 5d5c280bb81c..afe0dbc31c5e 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -1,16 +1,12 @@ -import { useAuth, useClerk, useUser } from "@clerk/react"; -import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; +import { useAuth, useClerk } from "@clerk/react"; +import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; import { useCallback, useEffect, useRef, useState } from "react"; import { buildConnectCliClerkAuthorizeUrl, connectCliSignInRedirectUrl, - readConnectCliAuthState, - readConnectCliCallbackResult, - rememberConnectCliAuthState, } from "../../cloud/connectCliAuth"; import { isElectron } from "../../env"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { AuthSurfaceShell } from "../auth/AuthSurfaceShell"; import { resolveClerkSignInProps } from "../clerk/authRedirect"; import { Button } from "../ui/button"; @@ -45,10 +41,10 @@ const invalidLinkMessage = { } as const; /** - * /connect: the URL the CLI prints for both flows. Waits for a Clerk session, - * then forwards the CLI's PKCE request to Clerk's authorize endpoint — with a - * loopback redirect URI when the request carries a port, so the code returns - * straight to the waiting CLI, and the hosted callback page otherwise. + * /connect: the URL the CLI prints for the loopback flow. Waits for a Clerk + * session, then forwards the CLI's PKCE request to Clerk's authorize endpoint + * with the loopback redirect URI so the code returns straight to the waiting + * CLI. Headless hosts use Clerk's device authorization page instead. */ export function ConnectCliAuthorizeSurface() { const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); @@ -61,9 +57,6 @@ export function ConnectCliAuthorizeSurface() { if (!request) { return; } - // Clerk redirects to the authorize endpoint itself once sign-in completes, - // so the callback's state check has to be armed before handing off. - rememberConnectCliAuthState(request.state); clerk.openSignIn( resolveClerkSignInProps( connectCliSignInRedirectUrl(request, window.location.href), @@ -88,7 +81,6 @@ export function ConnectCliAuthorizeSurface() { return; } redirecting.current = true; - rememberConnectCliAuthState(request.state); window.location.assign(authorizeUrl); }, [isLoaded, isSignedIn, openSignIn, request]); @@ -103,11 +95,7 @@ export function ConnectCliAuthorizeSurface() { return ( ); } - -/** - * /connect/callback: Clerk's redirect target. Shows the one-time code the - * user enters in the waiting terminal. - */ -export function ConnectCliCallbackSurface() { - const [result] = useState(readConnectCliCallbackResult); - const [expectedState] = useState(readConnectCliAuthState); - const { user } = useUser(); - const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "authentication code" }); - - if (!result) { - return ( - - - - ); - } - - // Fail closed: the legitimate callback always lands in the same browser - // that visited /connect (which recorded the state), so a missing or - // mismatched state means this page was reached some other way — the CSRF - // shape the state parameter exists to stop. Refuse to display a code. - if (expectedState === null || expectedState !== result.state) { - return ( - - - - ); - } - - const accountLabel = user?.primaryEmailAddress?.emailAddress ?? user?.username ?? null; - const authCode = encodeConnectAuthCode(result); - - return ( - - - -
-
- - One-time authorization code - - expires shortly -
- - {authCode} - -
- -
- -
- -

- Only enter this code in a terminal session you started yourself. Anyone holding it can link - their machine to your T3 Connect account while it is valid. -

-
- ); -} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 950a202a7b9f..6f1ae81eddb9 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -389,7 +389,7 @@ export function DevicePanel(props: { ) : null} {loaded && !hostBusy ? ( + ) : null}
); return actionsSource ? {player} : player; diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 74b189a02fc5..774398feda06 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -85,6 +85,8 @@ export interface NewProjectScriptInput { command: string; icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; + /** Setup scripts only: hold the agent until the script exits. */ + waitForSetup: boolean; keybinding: string | null; /** Optional URL to open in the in-app preview when this script runs. */ previewUrl: string | null; @@ -99,6 +101,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = { command: "", icon: "play", runOnWorktreeCreate: false, + waitForSetup: false, keybinding: null, previewUrl: null, autoOpenPreview: false, @@ -123,6 +126,7 @@ export function editorRequestForScript( command: script.command, icon: script.icon, runOnWorktreeCreate: script.runOnWorktreeCreate, + waitForSetup: script.runOnWorktreeCreate && script.async === false, keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)), previewUrl: script.previewUrl ?? null, autoOpenPreview: script.autoOpenPreview ?? false, @@ -158,6 +162,7 @@ export function ProjectScriptEditorDialog({ const [icon, setIcon] = useState("play"); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); + const [waitForSetup, setWaitForSetup] = useState(false); const [keybinding, setKeybinding] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); const [autoOpenPreview, setAutoOpenPreview] = useState(false); @@ -188,6 +193,7 @@ export function ProjectScriptEditorDialog({ setIcon(request.initial.icon); setIconPickerOpen(false); setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate); + setWaitForSetup(request.initial.waitForSetup); setKeybinding(request.initial.keybinding ?? ""); setPreviewUrl(request.initial.previewUrl ?? ""); setAutoOpenPreview(request.initial.autoOpenPreview); @@ -247,6 +253,7 @@ export function ProjectScriptEditorDialog({ command: trimmedCommand, icon, runOnWorktreeCreate, + waitForSetup: runOnWorktreeCreate && waitForSetup, keybinding: keybindingRule?.key ?? null, previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, @@ -396,6 +403,18 @@ export function ProjectScriptEditorDialog({ onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} /> +
- +
{children}
@@ -336,9 +339,6 @@ export function PullRequestSummaryTab({ // Keyed by the pull request, so opening another one starts at the end of its conversation // rather than wherever the last one had been read back to. const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); - const checksId = useId(); - const [expandedChecksUrl, setExpandedChecksUrl] = useState(null); - const showChecks = expandedChecksUrl === detail.url; const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; // Windowed by recency regardless of display order: expanding always reaches further back in // time, whether the newest comment currently reads first or last. @@ -474,7 +474,7 @@ export function PullRequestSummaryTab({ return (
-
+
} label="Reviewers"> @@ -604,7 +604,7 @@ export function PullRequestSummaryTab({
-
+
{bodyScope === detail.url ? ( )}
-
+
-
+
{detail.capabilities.ciRuns && ( No checks reported.

) : ( -
-
- Checks - -
-
- {(showChecks ? detail.checks : []).map((check, index) => { - const finding = { kind: "check", check } as const; - const failing = check.status === "failure" || check.status === "cancelled"; - return ( -
- - {/* Only where there is something to fix. A passing check has no failure to + + {/* Only where there is something to fix. A passing check has no failure to reproduce, and the button would be an invitation to waste a thread. */} - {onFixFinding && failing ? ( - - ) : null} -
- ); - })} -
-
+ {onFixFinding && failing ? ( + + ) : null} +
+ ); + }) )} -
+
{ + it("lists composer, provider, and pull request commands with editable defaults", () => { + const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, ""); + for (const command of [ + "composer.host", + "composer.effort", + "composer.mode", + "composer.workspace", + "composer.branch", + "composer.previousWorktree", + "modelPicker.previousProvider", + "modelPicker.nextProvider", + "thread.copyReference", + "pullRequest.copyNumber", + ]) { + expect(rows.find((row) => row.command === command)).toMatchObject({ + source: "Default", + conflicts: [], + }); + } + }); + it.each(["pu", "pull request", "copy link", "thread id"])( + "finds the copy link shortcut with %s", + (query) => { + const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, query); + expect(rows).toContainEqual( + expect.objectContaining({ command: "thread.copyReference", key: "mod+shift+c" }), + ); + }, + ); it("builds searchable rows with readable key and when values", () => { const rows = buildKeybindingRows( [ diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 65589d1bcd9f..a910c7e5535b 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -209,6 +209,7 @@ export function buildKeybindingRows( return rowsWithConflicts.filter((row) => { return ( row.command.toLowerCase().includes(normalizedQuery) || + commandLabel(row.command).toLowerCase().includes(normalizedQuery) || row.key.toLowerCase().includes(normalizedQuery) || row.when.toLowerCase().includes(normalizedQuery) || row.source.toLowerCase().includes(normalizedQuery) @@ -275,6 +276,7 @@ export function buildKeybindingCommandOptions( } export function commandLabel(command: KeybindingCommand): string { + if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread ID"; const raw = String(command); if (raw.startsWith("script.") && raw.endsWith(".run")) { return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index d1e2cfb527bc..aecd441c319a 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -27,7 +27,7 @@ import { type ServerRemoveKeybindingInput, type ServerUpsertKeybindingInput, } from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -1335,7 +1335,11 @@ export function KeybindingsSettingsPanel() { // fan out to every connected environment in the selection, so one // shortcut change reaches each machine the user runs T3 Code on. const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope(); - const keybindings = primaryEnvironment?.serverConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const serverKeybindings = primaryEnvironment?.serverConfig?.keybindings; + const keybindings = useMemo( + () => mergeWithDefaultKeybindings(serverKeybindings ?? []), + [serverKeybindings], + ); const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null; const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? []; const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { diff --git a/apps/web/src/components/settings/ProjectActionsSettings.tsx b/apps/web/src/components/settings/ProjectActionsSettings.tsx index aa4d7077ff6e..edbde5e7439a 100644 --- a/apps/web/src/components/settings/ProjectActionsSettings.tsx +++ b/apps/web/src/components/settings/ProjectActionsSettings.tsx @@ -111,6 +111,7 @@ export function ProjectActionsSettings() { command: fileScript.command, icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, + waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false, keybinding: null, previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index afbbf7671dfc..5a332b86057f 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -91,11 +91,12 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { )} to="/" > - - + {/* Center the visible capitals, without the font's ascender/descender space. */} + + diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 1bdd04693759..783453ac0082 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -67,7 +67,7 @@ describe("buildThreadActionMenuItems", () => { (item) => item.id === "snooze", ); expect(snooze?.disabled).toBe(true); - expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour"]); + expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour", "snooze:custom"]); }); it("disables title regeneration while one is in flight", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 5ba266f7709d..35b14ec44397 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -88,10 +88,13 @@ export function buildThreadActionMenuItems( label: "Snooze", icon: "clock", disabled: !state.canSnoozeNow, - children: state.snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}` as const, - label: `${preset.label} (${preset.whenLabel})`, - })), + children: [ + ...state.snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}` as const, + label: `${preset.label} (${preset.whenLabel})`, + })), + { id: "snooze:custom" as const, label: "Custom…", separatorBefore: true }, + ], }, ] : []), diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx new file mode 100644 index 000000000000..8488fd9ac6d6 --- /dev/null +++ b/apps/web/src/components/ui/calendar.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { DayPicker } from "@daypicker/react"; +import { ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon } from "lucide-react"; +import type * as React from "react"; +import { cn } from "~/lib/utils"; + +const buttonClassNames = + "relative flex size-(--cell-size) text-base sm:text-sm items-center justify-center rounded-lg text-foreground not-in-data-selected:hover:bg-accent disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"; + +const defaultComponents = { + Chevron: ({ + className, + orientation, + ...props + }: { + className?: string; + orientation?: "left" | "right" | "up" | "down"; + }): React.ReactElement => { + if (orientation === "left") { + return ( +