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 19a28f205d86..611d4cf44f75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,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/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-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/src/components/ProjectCloneBanner.tsx b/apps/mobile/src/components/ProjectCloneBanner.tsx new file mode 100644 index 000000000000..cca6a71e3f5e --- /dev/null +++ b/apps/mobile/src/components/ProjectCloneBanner.tsx @@ -0,0 +1,81 @@ +import { + projectCloneDisplayName, + projectCloneProgressSummary, + type ProjectCloneSnapshot, +} from "@t3tools/contracts"; +import { ActivityIndicator, Pressable, View } from "react-native"; + +import { cn } from "../lib/cn"; +import { AppText as Text } from "./AppText"; + +/** + * Live state of the clone that backs a freshly added project, shown above + * the composer while the draft waits for its files. Running clones offer + * Cancel; failed or cancelled ones offer Retry and Remove project. + */ +export function ProjectCloneBanner(props: { + readonly clone: ProjectCloneSnapshot; + readonly onCancel: () => 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/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/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5231228829d3..5724abb138c8 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( @@ -366,6 +368,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); @@ -575,6 +578,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 }); @@ -908,6 +920,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); @@ -938,6 +954,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: { @@ -960,8 +1018,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/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index d343f2dc8830..e67350f3d0f1 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} 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); @@ -3388,14 +3515,28 @@ export default function ChatView(props: ChatViewProps) { ? 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. + // the card belongs to the send, and the agent takes over from there. An + // async setup script keeps the snapshot running past the handoff and its + // row leaves the moment the script exits cleanly; a failed script stays + // for the rest of the turn so the exit code and terminal remain reachable. const worktreeSetupDoneAndTurnVisible = - worktreeSetup?.phase === "done" && activeThread?.latestTurn?.startedAt != null; + worktreeSetup?.phase === "done" && + activeThread?.latestTurn?.startedAt != null && + (!isWorking || !worktreeSetup.stages.some((stage) => stage.status === "failed")); useEffect(() => { if (!worktreeSetupDoneAndTurnVisible) return; setWorktreeSetupRef(null); setHeldWorktreeSetup(null); }, [worktreeSetupDoneAndTurnVisible]); + // The handoff entry only matters while the setup is still running: once it + // settles in any phase, a later mount of the thread must not adopt it. + const worktreeSetupSettledKey = + worktreeSetup && worktreeSetup.phase !== "running" && worktreeSetupRef + ? scopedThreadKey(scopeThreadRef(worktreeSetupRef.environmentId, worktreeSetupRef.threadId)) + : null; + useEffect(() => { + if (worktreeSetupSettledKey) pendingWorktreeSetupByThreadKey.delete(worktreeSetupSettledKey); + }, [worktreeSetupSettledKey]); const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { reportFailure: false, }); @@ -6248,10 +6389,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, @@ -6262,6 +6405,7 @@ export default function ChatView(props: ChatViewProps) { return [ ...feedbackBannerItems, ...usageLimitsItems, + ...projectCloneItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -6314,6 +6458,7 @@ export default function ChatView(props: ChatViewProps) { isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, + projectCloneBannerItem, resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, @@ -7399,6 +7544,12 @@ export default function ChatView(props: ChatViewProps) { ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey } : null, ); + if (baseBranchForWorktree) { + pendingWorktreeSetupByThreadKey.set( + scopedThreadKey(scopeThreadRef(environmentId, threadIdForSend)), + { environmentId, threadId: threadIdForSend }, + ); + } const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); @@ -9162,7 +9313,7 @@ export default function ChatView(props: ChatViewProps) { ? "Sending feedback" : threadDetailLoading ? "Messages loading" - : null + : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f33a9cce63b0..a8d1e57e5172 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -84,7 +84,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useServerConfigs, useThreadShells } from "../state/entities"; +import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; import { useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { @@ -643,6 +643,9 @@ function OpenCommandPaletteDialog(props: { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); + const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { + reportFailure: false, + }); const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -2198,28 +2201,80 @@ function OpenCommandPaletteDialog(props: { return; } + // Older servers only offer the blocking clone: the palette has to wait + // for git so it can add the project afterwards. + if (browseEnvironment?.serverConfig?.environment.capabilities.projectCloneTracking !== true) { + setIsRemoteProjectCloning(true); + const cloneResult = await cloneRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { + remoteUrl: addProjectCloneFlow.remoteUrl, + destinationPath, + }, + }); + setIsRemoteProjectCloning(false); + if (cloneResult._tag === "Failure") { + if (!isAtomCommandInterrupted(cloneResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: errorMessage(squashAtomCommandFailure(cloneResult)), + }), + ); + } + return; + } + await handleAddProject(cloneResult.value.cwd); + return; + } + + // The server creates the project and clones in the background; progress + // shows in a toast and in the draft's composer banner, so the palette + // closes as soon as the clone is under way. Only problems found before + // git runs (bad destination, unknown repository) come back here. + const projectId = newProjectId(); setIsRemoteProjectCloning(true); - const cloneResult = await cloneRepository({ + const startResult = await startProjectClone({ environmentId: addProjectCloneFlow.environmentId, input: { + projectId, + title: inferProjectTitleFromPath(destinationPath), + createdAt: new Date().toISOString(), remoteUrl: addProjectCloneFlow.remoteUrl, destinationPath, }, }); setIsRemoteProjectCloning(false); - if (cloneResult._tag === "Failure") { - if (!isAtomCommandInterrupted(cloneResult)) { + if (startResult._tag === "Failure") { + if (!isAtomCommandInterrupted(startResult)) { toastManager.add( stackedThreadToast({ type: "error", title: "Clone failed", - description: errorMessage(squashAtomCommandFailure(cloneResult)), + description: errorMessage(squashAtomCommandFailure(startResult)), }), ); } return; } - await handleAddProject(cloneResult.value.cwd); + setOpen(false); + const projectRef = scopeProjectRef(addProjectCloneFlow.environmentId, projectId); + // The create event usually lands before this call returns; give the shell + // stream a moment so the draft opens with its project resolved instead of + // flashing the project picker. + await waitForProject(projectRef, 3_000).catch(() => 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.tsx b/apps/web/src/components/ProjectFavicon.tsx index c545fb188880..17006889fb9c 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -112,44 +112,49 @@ function ProjectFaviconFallback({ }) { 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 ( - + + {identity.monogram} + + + + ); } 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.tsx b/apps/web/src/components/Sidebar.tsx index cc3487b595ed..6b0f3d7c11ef 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; @@ -439,7 +440,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; @@ -489,6 +490,19 @@ function SnoozePopoverButton(props: { ))} +
+ ); @@ -999,7 +1013,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; @@ -1333,7 +1347,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [onUnpin, threadRef], ); const handleSnoozePreset = useCallback( - (preset: SnoozePreset) => { + (preset: Pick) => { onSnooze(threadRef, preset); }, [onSnooze, threadRef], @@ -3644,7 +3658,7 @@ export default function Sidebar() { const performSnooze = useCallback( async ( threadRef: ScopedThreadRef, - preset: SnoozePreset, + preset: Pick, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { const threadKey = scopedThreadKey(threadRef); @@ -3678,7 +3692,7 @@ export default function Sidebar() { const attemptSnooze = useCallback( ( threadRef: ScopedThreadRef, - preset: SnoozePreset, + preset: Pick, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { void (async () => { @@ -3774,10 +3788,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 }, + ], }, ] : []), @@ -3790,9 +3807,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. @@ -4019,9 +4037,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/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 59b4a0c9856b..39cd8f8318a8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1148,17 +1148,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 +1167,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..89bcf214783f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -400,6 +400,8 @@ 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; }; export interface StableMessagesTimelineRowsState { @@ -1260,15 +1262,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 +1297,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", @@ -1300,6 +1335,11 @@ export function deriveMessagesTimelineRows(input: { export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; +/** True once the bootstrap handed off to the agent (async setup script may still run). */ +function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { + return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); +} + type MessagesTimelineRowsInput = Parameters[0]; export interface MessagesTimelineRowsProjection { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bc3a66f4e28c..b6383724e036 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1464,8 +1464,11 @@ function WorktreeSetupTimelineRow({ return ( ); diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 8b838fb81cc9..6fe54269cd10 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -10,16 +10,16 @@ import { ChevronRightIcon, CircleAlertIcon, CircleIcon, - GitBranchIcon, LaptopIcon, MinusIcon, TerminalIcon, XIcon, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { Button } from "~/components/ui/button"; import { Spinner } from "~/components/ui/spinner"; +import { observeVisibleAnimation } from "~/lib/visibleAnimation"; import { cn } from "~/lib/utils"; interface WorktreeSetupCardProps { @@ -74,20 +74,95 @@ function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) { function stageRowClassName(status: WorktreeSetupStage["status"]): string { switch (status) { - case "running": - return "text-foreground"; case "failed": return "text-destructive-foreground"; case "warning": return "text-warning-foreground"; case "pending": + return "text-secondary-label opacity-40"; + case "running": case "skipped": - return "text-secondary-label opacity-50"; case "done": return "text-secondary-label"; } } +/** Same shimmer treatment as the live tool rows in the timeline. */ +function ShimmerOverlay({ children }: { children: ReactNode }) { + return ( + + + {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/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/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))} /> +