diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 45524f6fcd33..0e11b50e1c83 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -40,7 +40,7 @@ Treat the overall testing or implementation loop—not an assistant turn or one - Do not stop the server merely because one verification pass completed or because you are yielding a response to the user. - Before starting another environment, check whether the existing process and browser tab still serve the task. Reuse them when healthy instead of discarding useful state. - On a later turn, verify that the existing process is alive and reuse its printed ports and base directory. If it exited, restart with the same base directory; create a new pairing token only when the browser session is no longer valid. -- Tell the user when a test environment remains available, including its non-secret web URL when useful. Never include a pairing token. +- Tell the user when a test environment remains available, including its non-secret web URL when useful. Include a pairing token only when the user still needs to pair (see below). ## Authenticate the browser on the first navigation @@ -50,24 +50,13 @@ Treat the overall testing or implementation loop—not an assistant turn or one 4. Wait for the pairing exchange and redirect to finish before navigating elsewhere. 5. Continue in the same browser context so its stored bearer session remains available. -Treat pairing URLs as secrets. Do not copy them into final responses, screenshots, committed files, or durable logs. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it. +Keep pairing URLs out of screenshots, committed files, and durable logs. When the user asked for a shared environment, the deliverable IS the full pairing URL — paste it in your reply, token and all; a bare origin is useless to them. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it, so never open a URL you handed to the user. ## Recover a consumed or expired pairing token -Create another token against the same database and web URL as the running dev server: +Run `node apps/server/src/bin.ts pair` from the repository root. It discovers the running dev server (worktree `.t3` first, same precedence as the dev runner) and prints a fresh `Pair URL` against the server's current web origin, including a `--share` tailnet origin. Pass `--base-dir ` only when the server was started with `--home-dir`, using the identical path. -```bash -T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ - --base-dir \ - --dev-url \ - --base-url \ - --ttl 15m \ - --label agent-ui-test -``` - -Use the `Pair URL` from this command once. Derive `` and `` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port. - -Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. A worktree-local `.t3` counts as explicit, so its state lives in `/.t3/userdata`. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. +Tokens from `pair` carry standard client scopes. The startup pairing URL carries admin scopes; if the user needs Settings → Connections management (`access:write`), restart the server and hand over the new startup URL instead. ## Inspect or seed SQLite state diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index f3e3dcfd8ce5..fbcd52e697dd 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -66,7 +66,7 @@ Use these client origins: - Android Emulator: `http://10.0.2.2:` - Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin -Always enter the complete `http://` origin; the mobile host field otherwise assumes HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. +Enter the complete `http://` origin to make the test transport explicit. Bare IP addresses default to HTTP, while bare hostnames default to HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. ## Start or reuse Metro safely @@ -125,31 +125,29 @@ Do not start, stop, erase, or reconfigure an emulator owned by another task. Tra ## Pair each client once -Issue a fresh credential against the running backend's exact base directory: +Use the bundled helper from the repository root. It issues a fresh credential against the running backend's exact base directory, opens the existing Add Environment route with the credential in an encoded query parameter, and asks that route to connect once: ```bash -T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ - --base-dir \ - --base-url \ - --ttl 15m \ - --label agent-mobile- +.agents/skills/test-t3-mobile/scripts/pair-client.sh \ + ios + +.agents/skills/test-t3-mobile/scripts/pair-client.sh \ + android ``` -In PowerShell, set `$env:T3CODE_PORT = ""` first and run the `node ... auth pairing create` command without the leading assignment. +Run only the command for the selected platform. The helper uses `http://127.0.0.1:` for iOS and `http://10.0.2.2:` for Android. Pass a fifth argument only when testing a non-development URL scheme. -If the visible Add Environment action is not exposed as a semantic target, open the app's registered route instead of guessing coordinates: +The helper opens this registered route: -```bash -xcrun simctl openurl 't3code-dev://connections/new' -adb -s shell am start -W \ - -a android.intent.action.VIEW \ - -d 't3code-dev://connections/new' \ - com.t3tools.t3code.dev +```text +t3code-dev://connections/new?pairingUrl=&autoConnect=1 ``` -Run only the command for the selected platform. +The Add Environment route owns the behavior: `pairingUrl` prefills its normal host and token inputs, while `autoConnect=1` submits once in development builds and returns to Home after success. Without `autoConnect`, the same route only prefills the form for manual inspection. + +Do not enter pairing hosts or tokens through simulator keyboard automation. Xcode's semantic typer sends HID-style key events through the simulator's active keyboard state, which can corrupt uppercase tokens and punctuation even when the host Mac uses a U.S. input source. The one-shot route is the deterministic pairing path. Use the visible form only as a fallback, and paste credentials rather than typing them character by character. -In T3 Code Dev, open Add Environment and enter the complete `` and newly printed `Token`. Verify the expected seeded projects appear before exercising the affected flow. +Verify the expected seeded projects appear before exercising the affected flow. Pairing credentials are secret, short-lived, and single-use. Create a different credential for every simulator, emulator, physical device, or browser. If an attempt fails, issue a new credential rather than retrying the old one. Do not expose tokens in screenshots, commits, or final responses. @@ -183,6 +181,8 @@ Keep local verification focused. Do not turn this workflow into a full repositor - **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. - **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory. - **A second client cannot pair:** pairing tokens are single-use; issue another token. +- **The pairing form opens but does not connect:** confirm the deep link uses the existing `connections/new` route, includes `autoConnect=1`, and carries a freshly minted encoded `pairingUrl`. +- **Pairing text changes case or punctuation:** do not retry semantic typing. Use `scripts/pair-client.sh`; the simulator keyboard layout and HID input path are not reliable for credentials. - **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`. - **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL. - **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator. diff --git a/.agents/skills/test-t3-mobile/scripts/pair-client.sh b/.agents/skills/test-t3-mobile/scripts/pair-client.sh new file mode 100755 index 000000000000..9caa060728ec --- /dev/null +++ b/.agents/skills/test-t3-mobile/scripts/pair-client.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "Usage: $0 [url-scheme]" >&2 + exit 2 +} + +[[ $# -ge 4 && $# -le 5 ]] || usage + +platform="$1" +device_id="$2" +server_port="$3" +base_dir="$4" +url_scheme="${5:-t3code-dev}" + +case "$platform" in + ios) + mobile_origin="http://127.0.0.1:${server_port}" + ;; + android) + mobile_origin="http://10.0.2.2:${server_port}" + ;; + *) + usage + ;; +esac + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +if ! pairing_output="$({ + T3CODE_PORT="$server_port" node apps/server/src/bin.ts auth pairing create \ + --base-dir "$base_dir" \ + --base-url "$mobile_origin" \ + --ttl 15m \ + --label "agent-mobile-${device_id:0:8}" +} 2>&1)"; then + echo "Could not mint a mobile pairing credential." >&2 + exit 1 +fi + +pairing_url="$(printf '%s\n' "$pairing_output" | sed -n 's/^Pair URL: //p' | tail -n 1)" +if [[ -z "$pairing_url" ]]; then + echo "Could not parse the mobile pairing URL." >&2 + exit 1 +fi + +deep_link="$(PAIRING_URL="$pairing_url" URL_SCHEME="$url_scheme" node - <<'NODE' +const query = new URLSearchParams({ + pairingUrl: process.env.PAIRING_URL, + autoConnect: "1", +}); +process.stdout.write(`${process.env.URL_SCHEME}://connections/new?${query}`); +NODE +)" + +case "$platform" in + ios) + xcrun simctl openurl "$device_id" "$deep_link" + ;; + android) + # adb shell re-joins its arguments and evaluates them through the device + # shell, so the deep link's `?`/`&` must be quoted once more for that shell. + adb -s "$device_id" shell \ + "am start -W -a android.intent.action.VIEW -d '$deep_link' com.t3tools.t3code.dev" \ + >/dev/null + ;; +esac + +echo "Opened the existing Add Environment route with a fresh pairing credential." diff --git a/.env.example b/.env.example index 61cdd66d246a..fc67dcef9478 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,14 @@ # Optional: T3 Connect source builds -# Leave these unset to disable optional T3 Connect features in local source builds. -# Release builds inject their public values at build time. Do not add server-side -# secrets to this file. +# `cp .env.example .env` enables T3 Connect against the production deployment. +# These are the same public identifiers baked into official release builds, not +# secrets. Remove or comment them out to build with cloud features disabled. +# Do not add server-side secrets to this file. -# Get these from the Clerk Dashboard under API keys, JWT templates, and OAuth applications. -# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... -# T3CODE_CLERK_JWT_TEMPLATE=t3-relay -# T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_... +# Production Clerk instance. To use your own, get these from the Clerk Dashboard +# under API keys, JWT templates, and OAuth applications. +T3CODE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsudDMuY29kZXMk +T3CODE_CLERK_JWT_TEMPLATE=t3-relay +T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r # Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API # hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk @@ -15,8 +17,9 @@ # T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile # T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com -# Get this from your relay deployment. `infra/relay` deploys update it automatically. -# T3CODE_RELAY_URL=https://relay.example.com +# Production relay. For a self-hosted relay, `infra/relay` deploys update it +# automatically. +T3CODE_RELAY_URL=https://relay.t3.codes # Optional: hosted app origin used by the CLI's out-of-band OAuth flow. # Defaults to https://app.t3.codes; override to test against a staging deployment. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 07438b251c5d..38a764eab6d7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,7 @@ body: attributes: value: | Use this form for broken behavior, regressions, crashes, or reliability problems. + Feature requests belong in [Discussions](https://github.com/pingdotgg/t3code/discussions/categories/ideas). Search existing issues first and keep the report focused on one problem. - type: checkboxes @@ -30,6 +31,7 @@ body: - apps/web - apps/server - apps/desktop + - apps/mobile - packages/contracts or packages/shared - Build, CI, or release tooling - Docs diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..4f4940ba6655 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Feature request + url: https://github.com/pingdotgg/t3code/discussions/categories/ideas + about: Suggest an improvement or new capability in Discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 53aab5166a56..000000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Feature request -description: Propose a scoped improvement or new capability. -title: "[Feature]: " -labels: - - enhancement - - needs-triage -body: - - type: markdown - attributes: - value: | - Use this form for new capabilities or meaningful improvements to existing behavior. - This repo is still early. Small, concrete requests that clearly explain the problem and scope are much easier to evaluate. - - - type: checkboxes - id: checks - attributes: - label: Before submitting - options: - - label: I searched existing issues and did not find a duplicate. - required: true - - label: I am describing a concrete problem or use case, not just a vague idea. - required: true - - - type: dropdown - id: area - attributes: - label: Area - description: Which part of the project would this change affect? - options: - - apps/web - - apps/server - - apps/desktop - - packages/contracts or packages/shared - - Build, CI, or release tooling - - Docs - - Not sure - validations: - required: true - - - type: textarea - id: problem - attributes: - label: Problem or use case - description: What are you trying to do? What is hard, slow, or impossible today? - placeholder: I want to reconnect to an existing provider session after a browser refresh without losing the current thread state. - validations: - required: true - - - type: textarea - id: proposal - attributes: - label: Proposed solution - description: Describe the behavior, API, or UX you want. - placeholder: Persist enough session metadata so the client can discover and reattach to the active provider session on load. - validations: - required: true - - - type: textarea - id: value - attributes: - label: Why this matters - description: Who benefits, and what outcome does this unlock? - placeholder: This would make reconnects predictable during network drops and reduce accidental duplicate sessions. - validations: - required: true - - - type: textarea - id: scope - attributes: - label: Smallest useful scope - description: What is the narrowest version of this request that would still solve your problem? - placeholder: A first pass only needs to support restoring the active session for the current thread. - validations: - required: true - - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: Workarounds, prior art, or other approaches you considered. - placeholder: I currently work around this by manually restarting the provider session, but that loses in-flight context. - - - type: textarea - id: tradeoffs - attributes: - label: Risks or tradeoffs - description: What costs, complexity, or edge cases should be considered? - placeholder: This may require careful handling when the underlying provider session has already exited. - - - type: textarea - id: references - attributes: - label: Examples or references - description: Links, screenshots, mockups, or comparable tools. - - - type: checkboxes - id: contribution - attributes: - label: Contribution - options: - - label: I would be open to helping implement this. diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 73376110d9a1..71e576e5c7e4 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -11,9 +11,11 @@ # Keep entries sorted alphabetically. github:adityavardhansharma github:binbandit +github:chrisdeeming github:chuks-qua github:cursoragent github:gbarros-dev +github:gfsaaser24 github:github-actions[bot] github:hwanseoc github:jamesx0416 @@ -25,7 +27,9 @@ github:Noojuno github:notkainoa github:PatrickBauer github:realAhmedRoach +github:saphid github:shiroyasha9 +github:StiensWout github:Yash-Singh1 github:eggfriedrice24 github:Ymit24 @@ -33,3 +37,5 @@ github:shivamhwp github:jappyjan github:justsomelegs github:UtkarshUsername +github:SunkenInTime +github:bil0000 diff --git a/.github/pr-assets/6424-after.svg b/.github/pr-assets/6424-after.svg new file mode 100644 index 000000000000..dbeb594a09da --- /dev/null +++ b/.github/pr-assets/6424-after.svg @@ -0,0 +1 @@ + diff --git a/.github/pr-assets/6424-before.svg b/.github/pr-assets/6424-before.svg new file mode 100644 index 000000000000..6b365bad6e69 --- /dev/null +++ b/.github/pr-assets/6424-before.svg @@ -0,0 +1 @@ + diff --git a/.github/pr-assets/6503-after.svg b/.github/pr-assets/6503-after.svg new file mode 100644 index 000000000000..db1c9cb54065 --- /dev/null +++ b/.github/pr-assets/6503-after.svg @@ -0,0 +1 @@ + diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs new file mode 100644 index 000000000000..94a02b7806dc --- /dev/null +++ b/.github/scripts/thread-transfer-report.cjs @@ -0,0 +1,429 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const ARTIFACT_NAME = "thread-transfer-results"; +const RESULT_FILE = "thread-transfer-result.json"; +const COMMENT_MARKER = ""; +const PROVIDERS = ["codex", "claudeAgent"]; +const OBSERVED_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "threadSnapshotDecodedBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const CEILING_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const SCENARIO_KEYS = [ + "id", + "historyTurns", + "historyCommandToolsPerTurn", + "historyMcpResultBytes", + "measuredCommandTools", + "measuredMcpResultBytes", +]; + +function resultShaMarker(sha) { + return ``; +} + +function assertObject(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function assertExactKeys(value, expected, label) { + assertObject(value, label); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new Error(`${label} has unexpected fields`); + } +} + +function assertMetric(value, label) { + if (!Number.isSafeInteger(value) || value < 0 || value > 1_000_000_000) { + throw new Error(`${label} must be a non-negative safe integer below 1,000,000,000`); + } +} + +function validateResult(value) { + assertExactKeys(value, ["schemaVersion", "scenario", "providers"], "result"); + if (value.schemaVersion !== 1) { + throw new Error("result.schemaVersion must be 1"); + } + + assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario"); + if (value.scenario.id !== "thread-transfer-v1") { + throw new Error("result.scenario.id is not supported"); + } + for (const key of SCENARIO_KEYS.slice(1)) { + assertMetric(value.scenario[key], `result.scenario.${key}`); + } + + assertExactKeys(value.providers, PROVIDERS, "result.providers"); + for (const provider of PROVIDERS) { + const entry = value.providers[provider]; + assertExactKeys(entry, ["observed", "ceiling"], `result.providers.${provider}`); + assertExactKeys(entry.observed, OBSERVED_KEYS, `result.providers.${provider}.observed`); + assertExactKeys(entry.ceiling, CEILING_KEYS, `result.providers.${provider}.ceiling`); + for (const key of OBSERVED_KEYS) { + assertMetric(entry.observed[key], `result.providers.${provider}.observed.${key}`); + } + for (const key of CEILING_KEYS) { + assertMetric(entry.ceiling[key], `result.providers.${provider}.ceiling.${key}`); + } + } + + return value; +} + +function readResult(directory) { + if (!directory) return undefined; + const file = path.join(directory, RESULT_FILE); + if (!fs.existsSync(file)) return undefined; + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.size > 64 * 1_024) { + throw new Error("thread transfer result must be a regular file smaller than 64 KiB"); + } + return validateResult(JSON.parse(fs.readFileSync(file, "utf8"))); +} + +function formatBytes(bytes) { + if (bytes < 1_024) return `${bytes} B`; + if (bytes >= 1_024 * 1_024) return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB`; + return `${(bytes / 1_024).toFixed(1)} KiB`; +} + +function formatValue(value, kind) { + return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value); +} + +function formatImpact(current, baseline, kind) { + if (baseline === undefined) return "—"; + const delta = current - baseline; + const prefix = delta > 0 ? "+" : delta < 0 ? "−" : ""; + const magnitude = formatValue(Math.abs(delta), kind); + const percent = + baseline === 0 ? "" : ` (${prefix}${Math.abs((delta / baseline) * 100).toFixed(1)}%)`; + return `${prefix}${magnitude}${percent}`; +} + +function sameScenario(left, right) { + return SCENARIO_KEYS.every((key) => left[key] === right[key]); +} + +const METRICS = [ + { key: "totalWireBytes", label: "Total thread wire", kind: "bytes" }, + { key: "threadSnapshotWireBytes", label: "Thread snapshot wire", kind: "bytes" }, + { + key: "measuredTurnWebSocketWireBytes", + label: "Live turn WebSocket wire", + kind: "bytes", + }, + { + key: "measuredTurnWebSocketDecodedBytes", + label: "Live turn WebSocket decoded", + kind: "bytes", + }, + { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" }, +]; + +function renderComment(input) { + const current = input.current; + const baseline = input.baseline; + const comparable = baseline !== undefined && sameScenario(current.scenario, baseline.scenario); + const rows = []; + const ceilingChanges = []; + let failed = false; + + for (const provider of PROVIDERS) { + for (const metric of METRICS) { + const observed = current.providers[provider].observed[metric.key]; + const ceiling = current.providers[provider].ceiling[metric.key]; + const baselineObserved = comparable + ? baseline.providers[provider].observed[metric.key] + : undefined; + const pass = observed <= ceiling; + failed ||= !pass; + rows.push( + `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, + ); + + if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) { + ceilingChanges.push( + `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, + ); + } + } + } + + const baselineLink = input.baselineRun + ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})` + : "unavailable"; + const currentLink = `[\`${input.currentRun.sha.slice(0, 7)}\`](${input.currentRun.url})`; + const notices = []; + if (!baseline) { + notices.push( + "> ℹ️ No successful `main` baseline artifact is available yet. This run establishes the initial measurement.", + ); + } else if (!comparable) { + notices.push( + "> ⚠️ The thread fixture changed, so impact percentages are not directly comparable to the `main` baseline.", + ); + } else if (!input.baselineRun.matchesBase) { + notices.push( + "> ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful `main` measurement shown below.", + ); + } + if (ceilingChanges.length > 0) { + notices.push( + `> ⚠️ **This PR changes transfer ceilings:**\n>\n${ceilingChanges.map((line) => `> ${line}`).join("\n")}`, + ); + } + + return [ + COMMENT_MARKER, + resultShaMarker(input.currentRun.sha), + "## Thread transfer impact", + "", + failed + ? "❌ One or more thread transfer ceilings were exceeded." + : "✅ Thread transfer remains within every enforced ceiling.", + ...(notices.length > 0 ? ["", ...notices] : []), + "", + "| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |", + "| --- | --- | ---: | ---: | ---: | ---: | --- |", + ...rows, + "", + `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`, + "", + "
", + "Scenario and decoded snapshot size", + "", + `${current.scenario.historyTurns} historical turns, ${current.scenario.historyCommandToolsPerTurn} command tools per turn, ${formatBytes(current.scenario.historyMcpResultBytes)} retained MCP result per historical turn, and a ${formatBytes(current.scenario.measuredMcpResultBytes)} retained result in the measured turn.`, + "", + ...PROVIDERS.map( + (provider) => + `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, + ), + "", + "
", + "", + "_Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed._", + ].join("\n"); +} + +async function artifactsForRun(github, owner, repo, runId) { + return github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: runId, + per_page: 100, + }); +} + +function findResultArtifact(artifacts) { + return artifacts.find((artifact) => artifact.name === ARTIFACT_NAME && !artifact.expired); +} + +async function resolve({ github, context, core }) { + const source = context.payload.workflow_run; + const { owner, repo } = context.repo; + if (source.event !== "pull_request") { + core.setOutput("publish", "false"); + return; + } + + let pullNumber = source.pull_requests?.[0]?.number; + if (!pullNumber) { + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: source.head_sha, per_page: 100 }, + ); + const matchingPulls = associated.filter( + (pull) => + pull.state === "open" && + pull.head.sha === source.head_sha && + pull.head.ref === source.head_branch, + ); + if (matchingPulls.length !== 1) { + core.info( + `Expected one open pull request for ${source.head_repository?.full_name ?? "unknown repository"}:${source.head_branch ?? "unknown branch"} at ${source.head_sha}; found ${matchingPulls.length}.`, + ); + core.setOutput("publish", "false"); + return; + } + pullNumber = matchingPulls[0].number; + } + if (!pullNumber) { + core.info("No open pull request is associated with the completed CI run."); + core.setOutput("publish", "false"); + return; + } + + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber }); + if (pull.head.sha !== source.head_sha) { + core.info(`Skipping stale CI result ${source.head_sha}; PR head is ${pull.head.sha}.`); + core.setOutput("publish", "false"); + return; + } + + const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id); + const sourceArtifact = findResultArtifact(sourceArtifacts); + const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { + owner, + repo, + workflow_id: source.workflow_id, + branch: pull.base.ref, + event: "push", + status: "success", + per_page: 100, + }); + const orderedRuns = [ + ...workflowRuns.filter((run) => run.head_sha === pull.base.sha), + ...workflowRuns.filter((run) => run.head_sha !== pull.base.sha), + ].slice(0, 20); + + let baselineRun; + for (const run of orderedRuns) { + const artifacts = await artifactsForRun(github, owner, repo, run.id); + if (findResultArtifact(artifacts)) { + baselineRun = run; + break; + } + } + + core.setOutput("publish", "true"); + core.setOutput("pull_number", String(pullNumber)); + core.setOutput("pr_artifact", sourceArtifact ? "true" : "false"); + core.setOutput("pr_run_id", String(source.id)); + core.setOutput("pr_sha", source.head_sha); + core.setOutput("pr_conclusion", source.conclusion ?? "unknown"); + core.setOutput("baseline_artifact", baselineRun ? "true" : "false"); + core.setOutput("baseline_run_id", baselineRun ? String(baselineRun.id) : ""); + core.setOutput("baseline_sha", baselineRun?.head_sha ?? ""); + core.setOutput( + "baseline_matches_base", + baselineRun?.head_sha === pull.base.sha ? "true" : "false", + ); +} + +async function upsertComment(github, context, pullNumber, body, options = {}) { + const { owner, repo } = context.repo; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pullNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && comment.body?.includes(COMMENT_MARKER), + ); + if ( + options.preserveResultSha && + existing?.body?.includes(resultShaMarker(options.preserveResultSha)) + ) { + return; + } + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body }); + } +} + +async function upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + expectedSha, + body, + options, +) { + const { owner, repo } = context.repo; + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (pull.head.sha !== expectedSha) { + core.info(`Skipping stale CI result ${expectedSha}; PR head is ${pull.head.sha}.`); + return false; + } + + await upsertComment(github, context, pullNumber, body, options); + return true; +} + +async function publish({ github, context, core }) { + const pullNumber = Number(process.env.PR_NUMBER); + if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) { + throw new Error("PR_NUMBER is invalid"); + } + + const current = readResult(process.env.PR_RESULT_DIR); + const currentRun = { + sha: process.env.PR_SHA, + conclusion: process.env.PR_CONCLUSION, + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.PR_RUN_ID}`, + }; + if (!current) { + await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + [ + COMMENT_MARKER, + "## Thread transfer impact", + "", + `⚠️ The latest [CI run](${currentRun.url}) did not produce a thread transfer result for \`${currentRun.sha.slice(0, 7)}\`.`, + "", + "_This comment will update automatically after the next completed run._", + ].join("\n"), + { preserveResultSha: currentRun.sha }, + ); + return; + } + + const baseline = readResult(process.env.BASELINE_RESULT_DIR); + const baselineRun = baseline + ? { + sha: process.env.BASELINE_SHA, + matchesBase: process.env.BASELINE_MATCHES_BASE === "true", + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`, + } + : undefined; + const body = renderComment({ current, baseline, currentRun, baselineRun }); + const published = await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + body, + ); + if (published) { + core.info(`Updated thread transfer report on PR #${pullNumber}.`); + } +} + +module.exports = { + publish, + readResult, + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +}; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs new file mode 100644 index 000000000000..4935864e46f0 --- /dev/null +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -0,0 +1,292 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +} = require("./thread-transfer-report.cjs"); + +function result(overrides = {}) { + const observed = { + totalWireBytes: 2_200_000, + threadSnapshotWireBytes: 1_950_000, + threadSnapshotDecodedBytes: 9_100_000, + measuredTurnWebSocketWireBytes: 250_000, + measuredTurnWebSocketDecodedBytes: 1_150_000, + measuredTurnWebSocketMessages: 15, + }; + const ceiling = { + totalWireBytes: 2_900_000, + threadSnapshotWireBytes: 2_600_000, + measuredTurnWebSocketWireBytes: 320_000, + measuredTurnWebSocketDecodedBytes: 1_550_000, + measuredTurnWebSocketMessages: 20, + }; + return { + schemaVersion: 1, + scenario: { + id: "thread-transfer-v1", + historyTurns: 10, + historyCommandToolsPerTurn: 5, + historyMcpResultBytes: 900_000, + measuredCommandTools: 20, + measuredMcpResultBytes: 1_100_000, + }, + providers: { + codex: { observed: { ...observed, ...overrides }, ceiling }, + claudeAgent: { observed, ceiling }, + }, + }; +} + +test("validates the fixed artifact schema", () => { + assert.equal(validateResult(result()).schemaVersion, 1); + assert.throws( + () => validateResult({ ...result(), injectedMarkdown: "@everyone" }), + /unexpected fields/, + ); + assert.throws( + () => validateResult(result({ totalWireBytes: "lots" })), + /non-negative safe integer/, + ); +}); + +test("renders baseline, impact, ceiling, and ceiling changes", () => { + const baseline = result(); + const current = result({ measuredTurnWebSocketWireBytes: 260_000 }); + current.providers.codex.ceiling = { + ...current.providers.codex.ceiling, + measuredTurnWebSocketWireBytes: 330_000, + }; + const comment = renderComment({ + current, + baseline, + currentRun: { + sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + conclusion: "success", + url: "https://github.com/pingdotgg/t3code/actions/runs/2", + }, + baselineRun: { + sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + matchesBase: true, + url: "https://github.com/pingdotgg/t3code/actions/runs/1", + }, + }); + + assert.match(comment, /Main baseline \| This PR \| Impact \| PR ceiling/); + assert.match(comment, /\+9\.8 KiB \(\+4\.0%\)/); + assert.match(comment, /This PR changes transfer ceilings/); + assert.match(comment, /312\.5 KiB → 322\.3 KiB/); + assert.match(comment, //); + assert.match( + comment, + //, + ); +}); + +test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { + const outputs = {}; + const listWorkflowRunArtifacts = () => {}; + const listWorkflowRuns = () => {}; + const listPullRequestsAssociatedWithCommit = () => {}; + const github = { + paginate: async (method, input) => { + if (method === listPullRequestsAssociatedWithCommit) { + return [ + { + number: 5350, + state: "open", + head: { sha: "head-sha", ref: "feature-branch", repo: null }, + }, + ]; + } + if (method === listWorkflowRunArtifacts) { + return [ + { + name: "thread-transfer-results", + expired: false, + runId: input.run_id, + }, + ]; + } + if (method === listWorkflowRuns) { + return [{ id: 1, head_sha: "base-sha" }]; + } + throw new Error("unexpected pagination call"); + }, + rest: { + actions: { listWorkflowRunArtifacts, listWorkflowRuns }, + pulls: { + get: async () => ({ + data: { + head: { sha: "head-sha" }, + base: { sha: "base-sha", ref: "main" }, + }, + }), + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }; + await resolve({ + github, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "true"); + assert.equal(outputs.pull_number, "5350"); + assert.equal(outputs.pr_artifact, "true"); + assert.equal(outputs.baseline_run_id, "1"); + assert.equal(outputs.baseline_matches_base, "true"); +}); + +test("does not guess when a fallback commit belongs to multiple PRs", async () => { + const outputs = {}; + const listPullRequestsAssociatedWithCommit = () => {}; + let fetchedPull = false; + await resolve({ + github: { + paginate: async (method) => { + assert.equal(method, listPullRequestsAssociatedWithCommit); + return [5350, 5351].map((number) => ({ + number, + state: "open", + head: { + sha: "head-sha", + ref: "feature-branch", + repo: { full_name: "pingdotgg/t3code" }, + }, + })); + }, + rest: { + actions: {}, + pulls: { + get: async () => { + fetchedPull = true; + }, + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "false"); + assert.equal(fetchedPull, false); +}); + +test("does not publish a stale result after the PR head advances", async () => { + let listedComments = false; + const info = []; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => { + listedComments = true; + return []; + }, + rest: { + issues: { + listComments: () => {}, + createComment: () => { + throw new Error("must not create a stale comment"); + }, + updateComment: () => { + throw new Error("must not update a stale comment"); + }, + }, + pulls: { + get: async () => ({ data: { head: { sha: "new-head-sha" } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: (message) => info.push(message) }, + 5350, + "old-head-sha", + "stale body", + ); + + assert.equal(published, false); + assert.equal(listedComments, false); + assert.deepEqual(info, ["Skipping stale CI result old-head-sha; PR head is new-head-sha."]); +}); + +test("preserves a successful result when a same-SHA rerun has no artifact", async () => { + let updatedComment = false; + const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => [ + { + id: 1, + user: { login: "github-actions[bot]" }, + body: `\n`, + }, + ], + rest: { + issues: { + listComments: () => {}, + createComment: () => { + updatedComment = true; + }, + updateComment: () => { + updatedComment = true; + }, + }, + pulls: { + get: async () => ({ data: { head: { sha } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: () => {} }, + 5350, + sha, + "missing artifact warning", + { preserveResultSha: sha }, + ); + + assert.equal(published, true); + assert.equal(updatedComment, false); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e51867cbe7d..052a8c20cf78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,8 +84,29 @@ jobs: run: vp run --filter @t3tools/desktop ensure:electron - name: Test + env: + T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md + T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json run: vp run test + - name: Publish transfer budget report + if: always() + run: | + if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then + tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md" + else + echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload thread transfer result + if: always() + uses: actions/upload-artifact@v7 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer-result.json + if-no-files-found: ignore + retention-days: 30 + - name: Test resource monitor run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 2e61de6039e8..4ad9f4f7672b 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -5,6 +5,25 @@ name: Mobile EAS Production # in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. +# +# Every merge to main that touches the mobile app reconciles, per platform: +# 1. Store builds: if the latest production build's version differs from +# app.config.ts, cut a new build and submit it (TestFlight + Play internal +# track). Bumping `version` is therefore all it takes to +# start the next release train — the first build of a version enters +# external-TestFlight beta review immediately, and later builds of the +# same version auto-approve until that version is released. After App +# Store approval, Apple closes the release train and `version` must be +# bumped before another iOS build can be submitted. Releasing to the App +# Store stays a manual App Store Connect step. +# 2. OTA: publish a production-channel update for each platform where at +# least one finished production build matches the current native +# fingerprint. Old-version binaries with a matching fingerprint receive +# it too. When native drift means no binary could install the update, +# it is skipped and flagged in the job summary instead of published +# into the void. +# workflow_dispatch remains as a manual override for both modes (e.g. to +# retry an errored build or force an OTA). on: workflow_dispatch: inputs: @@ -25,14 +44,38 @@ on: - ios - android - all + version: + description: "Optional build version override (blank uses app.config.ts; an override is committed before building)" + required: false + type: string message: description: "OTA update message (mode=update only)" required: false type: string + push: + branches: [main] + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - scripts/** + - patches/** + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/mobile-eas-production.yml + +# Serialize runs so OTAs publish in merge order. GitHub keeps at most one +# queued run per group, so a burst of merges collapses into one run of the +# newest commit — intermediate commits don't need their own OTA. +concurrency: + group: mobile-eas-production + cancel-in-progress: false jobs: production: - name: EAS Production ${{ inputs.mode }} + name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }} runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read @@ -52,11 +95,21 @@ jobs: echo "EXPO_TOKEN is not available; skipping EAS production job." fi + - id: version_app_token + name: Mint release app token for version override + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout if: steps.expo-token.outputs.present == 'true' uses: actions/checkout@v6 with: fetch-depth: 0 + token: ${{ steps.version_app_token.outputs.token || github.token }} # No sparse-checkout here: it makes actions/checkout fetch with # --filter=blob:none, and eas-cli archives the project via # `git clone --depth 1 file://`, which fails (exit 128) @@ -98,15 +151,74 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas env:pull production --non-interactive - - name: Build and submit - if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' + - name: Apply manual version override + if: steps.version_app_token.outcome == 'success' + env: + GH_TOKEN: ${{ steps.version_app_token.outputs.token }} + APP_SLUG: ${{ steps.version_app_token.outputs.app-slug }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + if [ "$GITHUB_REF_TYPE" != "branch" ]; then + echo "Version overrides require dispatching this workflow from a branch; received $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." >&2 + exit 1 + fi + if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then + echo "Version override must contain two or three dot-separated integers; received '$RELEASE_VERSION'." >&2 + exit 1 + fi + + node --input-type=module -e ' + import fs from "node:fs"; + const path = "apps/mobile/app.config.ts"; + const source = fs.readFileSync(path, "utf8"); + const next = source.replace( + /^( version: ")[^"]+(".*)$/m, + `$1${process.env.RELEASE_VERSION}$2`, + ); + if (next === source && !source.includes(` version: "${process.env.RELEASE_VERSION}"`)) { + throw new Error("Could not update app version"); + } + fs.writeFileSync(path, next); + ' + vp fmt apps/mobile/app.config.ts + + if git diff --quiet -- apps/mobile/app.config.ts; then + echo "app.config.ts is already at $RELEASE_VERSION; no version commit needed." + exit 0 + fi + + user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" + git config user.name "${APP_SLUG}[bot]" + git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" + git add apps/mobile/app.config.ts + git commit \ + -m "chore(mobile): bump app version to $RELEASE_VERSION" \ + -m "Co-authored-by: codex " + git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" + + - name: Summarize manual build version + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' + working-directory: apps/mobile + run: | + version="$(npx expo config --json --type public | jq -r '.version')" + { + echo "## Manual production build" + echo + echo "- App version: \`$version\`" + echo "- Platform: \`${{ inputs.platform }}\`" + echo + echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Build and submit (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait - - name: Publish OTA update - if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update' + - name: Publish OTA update (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} @@ -117,3 +229,62 @@ jobs: --platform ${{ inputs.platform }} \ --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ --non-interactive + + # No --status filter on build:list: an in-queue/in-progress build must + # count as existing, or every merge during the build window would cut a + # duplicate. After an errored build, retry via workflow_dispatch + # mode=build — pushes won't re-trigger it until the app version changes. + - id: store_builds + name: Ensure store builds exist for the current app version + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' + continue-on-error: true + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + failed=0 + version="$(npx expo config --json --type public | jq -r '.version')" + for platform in ios android; do + latest="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')" + if [ "$latest" = "$version" ]; then + echo "$platform: production build for $version already exists (or is in progress)" + continue + fi + echo "$platform: latest production build is $latest, app.config.ts says $version — building" + if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then + echo ":building_construction: $platform: scheduled production build and submission for $version" >> "$GITHUB_STEP_SUMMARY" + else + failed=1 + echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" + fi + done + exit "$failed" + + - name: Publish fingerprint-gated OTA + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" + for platform in ios android; do + # eas-cli prints an environment-loaded notice to stdout before the + # JSON even with --json, so discard everything before the document. + hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + matching="$(eas build:list --platform "$platform" --build-profile production --status finished --fingerprint-hash "$hash" --limit 1 --json --non-interactive | jq 'length')" + if [ "$matching" -gt 0 ]; then + eas update \ + --channel production \ + --environment production \ + --platform "$platform" \ + --message "$message" \ + --non-interactive + echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" + else + echo ":warning: $platform: no finished production build matches fingerprint \`$hash\` — OTA skipped; JS changes reach $platform only once a matching build ships" >> "$GITHUB_STEP_SUMMARY" + fi + done + + - name: Propagate store build failure + if: steps.store_builds.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml new file mode 100644 index 000000000000..fd98817cd105 --- /dev/null +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -0,0 +1,205 @@ +name: Mobile Fingerprint Check + +# Detects whether a PR changes the native fingerprint — i.e. whether merging +# it would leave main un-OTA-able until a new store build ships. Native-change +# PRs get the "📱 Native Change" label so they can be held and merged as a +# batch right before the next store submission, keeping main OTA-able for +# everything else in between. (Once one native PR merges, every later merge +# inherits the drifted fingerprint and loses OTA reach too — that is why the +# signal has to fire before merge, not after.) +# +# The check is advisory: it always passes, the label is the signal. Both +# fingerprints are computed in this one job (same OS, same corepack-pinned +# pnpm), so the comparison is self-consistent; no EXPO_TOKEN needed. +on: + pull_request: + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - scripts/** + - patches/** + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/mobile-fingerprint-check.yml + +concurrency: + group: mobile-fingerprint-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + fingerprint: + name: Native fingerprint diff + runs-on: blacksmith-8vcpu-ubuntu-2404 + permissions: + contents: read + issues: write + pull-requests: write + env: + APP_VARIANT: production + NODE_OPTIONS: --max-old-space-size=8192 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # Default pull_request checkout is the merge commit (PR applied on + # top of base), so the "head" fingerprint is the state main would + # actually be in after merging — stale branches compare cleanly. + fetch-depth: 0 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/mobile... + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Fingerprint merge result + working-directory: apps/mobile + run: | + mkdir -p "$RUNNER_TEMP/fp/head" "$RUNNER_TEMP/fp/base" + for platform in ios android; do + npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/head/$platform.json" + done + + - name: Fingerprint base + run: | + git checkout --quiet "${{ github.event.pull_request.base.sha }}" + # Re-sync node_modules to the base commit's lockfile before + # fingerprinting — a dep-changing PR must not fingerprint the base + # against head's installed packages. + pnpm install --filter=@t3tools/mobile... + cd apps/mobile + for platform in ios android; do + npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/base/$platform.json" + done + + - id: compare + name: Compare fingerprints + run: | + changed="" + { + echo "## Native fingerprint diff" + echo + for platform in ios android; do + head_hash="$(jq -r .hash "$RUNNER_TEMP/fp/head/$platform.json")" + base_hash="$(jq -r .hash "$RUNNER_TEMP/fp/base/$platform.json")" + if [ "$head_hash" = "$base_hash" ]; then + echo "- ✅ **$platform**: unchanged (\`$head_hash\`) — OTA-compatible" + continue + fi + changed="$changed $platform" + echo "- 📱 **$platform**: \`$base_hash\` → \`$head_hash\` — merging requires a new native build before OTAs work again" + jq -r -n \ + --slurpfile h "$RUNNER_TEMP/fp/head/$platform.json" \ + --slurpfile b "$RUNNER_TEMP/fp/base/$platform.json" ' + ($b[0].sources | map({ (.filePath // .id): .hash }) | add // {}) as $bm + | $h[0].sources[] + | select($bm[(.filePath // .id)] != .hash) + | " - \(.type): `\(.filePath // .id)`"' + done + } >> "$GITHUB_STEP_SUMMARY" + echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT" + + - name: Sync native change label + # Fork PRs get a read-only token under pull_request; the check stays + # advisory there (summary only). This workflow must not move to + # pull_request_target — it installs and runs PR code. + if: github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@v8 + env: + CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }} + with: + script: | + const managedLabel = { + name: "📱 Native Change", + color: "d93f0b", + description: + "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", + }; + const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; + const issueNumber = context.payload.pull_request.number; + + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + }); + + if ( + existing.color !== managedLabel.color || + (existing.description ?? "") !== managedLabel.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); + + if (nativeChanged && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [managedLabel.name], + }); + } else if (!nativeChanged && hasLabel) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: managedLabel.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + core.info( + `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, + ); diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 0aa9f30a2ff2..c64bccacdca8 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -21,6 +21,19 @@ on: - both - dark - light + theme: + description: Palette to capture (all multiplies the run by six) + required: true + default: t3-code + type: choice + options: + - t3-code + - t3-chat + - grove + - ocean + - ember + - iris + - all permissions: contents: read @@ -33,7 +46,9 @@ jobs: name: iPhone 6.9, iPhone 6.5, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' runs-on: blacksmith-12vcpu-macos-26 - timeout-minutes: 60 + # Capturing every palette multiplies the device matrix by six, and only the + # one native build is shared between them. + timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} steps: - name: Checkout uses: actions/checkout@v6 @@ -52,6 +67,7 @@ jobs: args: - --filter=@t3tools/mobile... - --filter=@t3tools/scripts... + - --filter=t3... - name: Expose pnpm run: | @@ -61,10 +77,10 @@ jobs: "$vp_pnpm_bin/pnpm" --version - name: Capture iOS showcase - run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" - name: Validate App Store Connect assets - run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only - name: Upload iOS screenshots if: always() @@ -79,7 +95,9 @@ jobs: name: Android phone, 7-inch tablet, and 10-inch tablet if: inputs.platform == 'all' || inputs.platform == 'android' runs-on: blacksmith-16vcpu-ubuntu-2404 - timeout-minutes: 60 + # Capturing every palette multiplies the device matrix by six, and only the + # one native build is shared between them. + timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} env: T3_SHOWCASE_ANDROID_ABI: x86_64 steps: @@ -100,6 +118,7 @@ jobs: args: - --filter=@t3tools/mobile... - --filter=@t3tools/scripts... + - --filter=t3... - name: Expose pnpm run: | @@ -135,10 +154,10 @@ jobs: cores: 8 ram-size: 4096M disable-animations: false - script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" + script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" - name: Validate Google Play assets - run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only + run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only - name: Upload Android screenshots if: always() diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml new file mode 100644 index 000000000000..62f8fd1f5470 --- /dev/null +++ b/.github/workflows/publish-aur.yml @@ -0,0 +1,65 @@ +name: Publish AUR package + +# See packaging/aur/README.md. + +on: + workflow_call: + inputs: + release_tag: + required: true + type: string + pkgrel: + required: false + default: "1" + type: string + secrets: + AUR_SSH_PRIVATE_KEY: + required: true + workflow_dispatch: + inputs: + release_tag: + description: "Release tag to publish" + required: true + type: string + pkgrel: + description: "Arch package release override" + required: false + default: "1" + type: string + +permissions: + contents: read + +concurrency: + group: publish-aur + cancel-in-progress: false + +jobs: + publish: + name: Validate and publish + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 + container: + image: archlinux:base-devel + + steps: + - name: Install Arch packaging tools + run: pacman -Syu --noconfirm --needed git github-cli jq namcap openssh sudo + + - name: Checkout packaging sources + uses: actions/checkout@v6 + + - name: Create unprivileged build user + run: | + useradd --create-home builder + install -Dm0440 /dev/stdin /etc/sudoers.d/builder <<'EOF' + builder ALL=(root) NOPASSWD: /usr/bin/pacman + EOF + + - name: Validate and publish package sources + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + PKGREL: ${{ inputs.pkgrel || '1' }} + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: packaging/aur/scripts/release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce47d6e6ed73..6abd702bf889 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -746,16 +746,10 @@ jobs: needs: [preflight, build, publish_cli] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + timeout-minutes: 30 + permissions: + contents: write steps: - - id: app_token - name: Mint release app token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - name: Checkout uses: actions/checkout@v6 with: @@ -823,7 +817,7 @@ jobs: - name: Publish release if: needs.preflight.outputs.previous_tag != '' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -840,11 +834,11 @@ jobs: release-assets/*.blockmap release-assets/*.yml fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} + token: ${{ github.token }} - name: Publish first release if: needs.preflight.outputs.previous_tag == '' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -860,7 +854,17 @@ jobs: release-assets/*.blockmap release-assets/*.yml fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} + token: ${{ github.token }} + + publish_aur: + name: Publish AUR package + needs: [preflight, release] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + uses: ./.github/workflows/publish-aur.yml + with: + release_tag: ${{ needs.preflight.outputs.tag }} + secrets: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} deploy_web: name: Deploy hosted web app diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml new file mode 100644 index 000000000000..23eec72923bd --- /dev/null +++ b/.github/workflows/thread-transfer-report.yml @@ -0,0 +1,75 @@ +name: Thread Transfer Report + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + publish: + name: Publish PR comment + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-24.04 + concurrency: + group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} + cancel-in-progress: true + steps: + # workflow_run has a write-capable token even for fork PRs. Only load the + # publisher from the trusted default branch and never execute PR code. + - name: Checkout trusted publisher + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + + - name: Test trusted publisher + run: node --test .github/scripts/thread-transfer-report.test.cjs + + - id: resolve + name: Resolve PR and baseline artifacts + uses: actions/github-script@v8 + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.resolve({ github, context, core }); + + - name: Download PR result + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/pr + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.pr_run_id }} + + - name: Download main baseline + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/main + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.baseline_run_id }} + + - name: Update thread transfer comment + if: steps.resolve.outputs.publish == 'true' + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ steps.resolve.outputs.pull_number }} + PR_SHA: ${{ steps.resolve.outputs.pr_sha }} + PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }} + PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }} + PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr + BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }} + BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }} + BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }} + BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.publish({ github, context, core }); diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml new file mode 100644 index 000000000000..f9cc3b063fcd --- /dev/null +++ b/.github/workflows/web-preview.yml @@ -0,0 +1,132 @@ +name: Web Preview + +# Label a PR `preview:web` to get a hosted-web preview deployment on Vercel for +# that push and every subsequent push. The deployment is a plain (non-prod, +# non-aliased) deploy into the existing hosted-web Vercel project, so the +# latest/nightly channel aliases are never touched. +# +# The build intentionally omits the T3 Connect cloud config (Clerk keys, relay +# URL): previews boot as the hosted-static app with manual pairing only. Pair a +# server into a preview with `t3 pair --tailscale` (or any reachable HTTPS +# backend) and open the pairing URL against the preview origin. +# +# The preview must be opened at the exact deployment URL from the PR comment. +# Vite bakes that URL in as the hosted origin (via VERCEL_URL), and +# `isHostedStaticApp` matches on origin, so branch-alias URLs will not +# self-identify as the hosted app. + +on: + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: web-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy web preview + # Same-repo PRs only: fork PRs do not receive the Vercel secrets, and this + # workflow should skip rather than fail for them. On `labeled` events, only + # the preview label itself triggers a deploy. + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'preview:web') && + (github.event.action != 'labeled' || github.event.label.name == 'preview:web') + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + - --filter=@t3tools/web... + + - id: deploy + name: Deploy preview + shell: bash + run: | + set -euo pipefail + + if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then + echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 + exit 1 + fi + + vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" + + deployment_url="$( + vp dlx vercel@53.1.1 deploy \ + --archive=tgz \ + --yes \ + --token "$VERCEL_TOKEN" \ + --scope "$vercel_scope" + )" + + echo "Deployed $deployment_url" + echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" + + - name: Comment deployment URL + uses: actions/github-script@v8 + env: + DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment_url }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const marker = ""; + const body = [ + marker, + "### Web preview", + "", + `${process.env.DEPLOYMENT_URL} (for ${process.env.HEAD_SHA.slice(0, 7)})`, + "", + "Open this exact URL — the hosted-app origin is baked in at build time.", + "Pair a server into it with `t3 pair --tailscale`, or paste a host + pairing", + "code under Settings → Connections.", + ].join("\n"); + + const { data: comments } = await 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, + }); + } diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md new file mode 100644 index 000000000000..8ec720742759 --- /dev/null +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -0,0 +1,82 @@ +--- +title: UI Consistency +model: claude-opus-5 +effort: high +input: full_diff +tools: + - browse_code + - git_tools + - github_api_read_only + - modify_pr +include: + - "apps/web/src/**/*.ts" + - "apps/web/src/**/*.tsx" + - "apps/web/src/**/*.css" +conclusion: failure +showToolCalls: true +--- + +# UI consistency review + +Review changed web UI code and directly affected call sites for consistency with the shared component system, Tailwind ownership, and the behavioral constraints below. Apply these rules when a pull request creates, moves, or modifies controls or styling. Do not demand unrelated repository-wide cleanup. + +The goal is not to minimize CSS or class counts at any cost. The goal is to put each behavior in the smallest correct owner while preserving interaction, theming, accessibility, layout, and browser behavior. + +## Shared controls and variants + +- Prefer the core UI primitives in `apps/web/src/components/ui` over native controls or locally reconstructed primitives. In ordinary product UI, a raw ` + + event.preventDefault()} + onClick={toggleAspectRatio} + /> + } + > + {aspectRatio === null ? ( + + ) : ( + + )} + + + {aspectRatio === null ? "Lock aspect ratio" : "Unlock aspect ratio"} + + + +
+ {result._tag === "Success" ? ( +
+            {result.value.contents}
+            {result.value.truncated ? "\n… (truncated)" : ""}
+          
+ ) : result._tag === "Failure" ? ( +

Could not load the script.

+ ) : ( +

Loading…

+ )} +
+ + ); +} + +/** + * Collapsible phase section. A phase opens when it becomes active, then keeps + * that shape as it settles so completion never yanks rows out from under the + * user. Manual toggles stick until a later activation begins. + */ +function PhaseSection({ + phase, + defaultOpen = false, +}: { + phase: AgentPanelWorkflowGroup["phases"][number]; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen || phase.state === "running"); + const previousState = useRef(phase.state); + + useEffect(() => { + if (previousState.current !== "running" && phase.state === "running") { + setOpen(true); + } + previousState.current = phase.state; + }, [phase.state]); + + return ( +
+ + {open ? phase.members.map((member) => ) : null} +
+ ); +} + +/** Expanded workflow: phase rail + full phase tree. */ +function ExpandedWorkflowSection({ + group, + environmentId, + threadId, + onCollapse, +}: { + group: AgentPanelWorkflowGroup; + environmentId: EnvironmentId | null; + threadId: ThreadId | null; + onCollapse: () => void; +}) { + const [scriptOpen, setScriptOpen] = useState(false); + const members = workflowMembers(group); + const settled = members.filter( + (member) => + member.status === "completed" || + member.status === "failed" || + member.status === "cancelled" || + member.status === "interrupted", + ).length; + const scriptPath = group.workflow.runHandles?.scriptPath; + const canShowScript = scriptPath !== undefined && environmentId !== null && threadId !== null; + return ( +
+
+ + + {group.workflow.workflowName ?? group.workflow.title} + + {canShowScript ? ( + + ) : null} + + {settled}/{members.length} settled + + +
+ + {scriptOpen && canShowScript ? ( + setScriptOpen(false)} + /> + ) : null} + {group.phases.map((phase) => ( + + ))} + {group.unphasedMembers.map((member) => ( + + ))} + {group.phases.length === 0 && group.unphasedMembers.length === 0 ? ( + + ) : null} +
+ ); +} + +/** + * Collapsed workflow: one summary line. The parent owns expansion so a live + * workflow keeps its shape when it settles. + */ +function CollapsedWorkflowSection({ + group, + onExpand, +}: { + group: AgentPanelWorkflowGroup; + onExpand: () => void; +}) { + const members = workflowMembers(group); + const failed = members.filter((member) => member.status === "failed").length; + // Coordinator usage may already aggregate members (panel-footer rule): + // count it only when there are no member rows to sum. + const totalTokens = members.reduce( + (sum, member) => sum + (member.usage?.totalTokens ?? 0), + members.length === 0 ? (group.workflow.usage?.totalTokens ?? 0) : 0, + ); + const elapsed = + group.workflow.startedAt && group.workflow.completedAt + ? elapsedBetween(group.workflow.startedAt, group.workflow.completedAt) + : null; + return ( +
+ +
+ ); +} + +/** A workflow's open state is presentation state, not a status derivative. */ +function WorkflowSection({ + group, + environmentId, + threadId, +}: { + group: AgentPanelWorkflowGroup; + environmentId: EnvironmentId | null; + threadId: ThreadId | null; +}) { + const [open, setOpen] = useState(() => workflowIsLive(group)); + return open ? ( + setOpen(false)} + /> + ) : ( + setOpen(true)} /> + ); +} + +export function AgentsPanel({ + model, + environmentId = null, + threadId = null, +}: { + model: AgentPanelModel; + environmentId?: EnvironmentId | null; + threadId?: ThreadId | null; +}) { + if (!model.hasAgents) { + return ( +
+ +

No agents yet

+

+ When this thread spawns subagents or runs a workflow, they show up here with live status, + activity, and token usage. +

+
+ ); + } + + return ( +
+ +
+ {model.workflows.map((group) => ( + + ))} + {model.directAgents.length > 0 ? ( +
+
+ Direct spawns +
+ {model.directAgents.map((agent) => ( + + ))} +
+ ) : null} +
+
+
+ + {model.runningCount + model.waitingCount > 0 ? ( + + ● {model.runningCount + model.waitingCount} working + + ) : null} + {model.idleCount > 0 ? {model.idleCount} idle : null} + {model.settledCount > 0 ? {model.settledCount} settled : null} + + Σ {formatSubagentTokenCount(model.totalTokens)} tok +
+
+ ); +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 5c6acd62aea8..a3ba76679689 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -10,14 +10,20 @@ import { import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; -import { getLocalStorageItem } from "../hooks/useLocalStorage"; +import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useEnvironmentIdentificationMode, useSidebarV2Enabled } from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; +import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; -import ThreadSidebarV2 from "./SidebarV2"; -import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; +import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { + resolveSidebarStageFocusRingOffsetClass, + useSidebarStageBackdropVariant, +} from "./SidebarStageBackdrop"; +import { useProjects } from "../state/entities"; import { resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, @@ -90,8 +96,11 @@ function SidebarControl() { }, [keybindings, toggleSidebar]); return ( + // The right-side layout controls carry mr-px (border compensation inside + // the panel), so the trigger mirrors it: both clusters sit one extra pixel + // off their edge and the titlebar reads symmetric.
@@ -102,7 +111,10 @@ function SidebarControl() { "pointer-events-auto", isSidebarVisible && stageBackdropVariant && - "[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!", + "focus-visible:ring-white/90 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white! [:hover,[data-pressed]]:bg-white/15", + isSidebarVisible && + stageBackdropVariant && + resolveSidebarStageFocusRingOffsetClass(stageBackdropVariant), )} aria-label="Toggle main sidebar" /> @@ -116,15 +128,21 @@ function SidebarControl() { ); } +// Settings swaps the thread sidebar out of the tree. Keep the lightweight +// project projection subscribed so returning to a draft never renders the +// zero-project state while the environment snapshot reconnects. +function ProjectProjectionRetention() { + useProjects(); + return null; +} + export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); - const sidebarV2Enabled = useSidebarV2Enabled(); - // Settings routes render the settings nav, which lives in the v1 component - // and is identical for both sidebars — so v1 stays mounted there. + const legacySidebarEnabled = useLegacySidebarEnabled(); + // Settings routes show the settings nav in place of whichever thread + // sidebar is active. const pathname = useLocation({ select: (location) => location.pathname }); const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); - const useSidebarV2 = sidebarV2Enabled && !isOnSettings; - const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); // Subscribed rather than read once: the clamp must track live window size, @@ -132,6 +150,14 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { // that would otherwise refresh a render-time snapshot. const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth); + const resetSidebarWidth = () => { + try { + removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY); + } catch (error) { + console.error("Could not clear persisted thread sidebar width.", error); + } + setSidebarWidth(resolveInitialThreadSidebarWidth(null, viewportWidth)); + }; const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" @@ -184,11 +210,11 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return ( + - {useSidebarV2 ? : } - + {isOnSettings ? ( + <> + + + + ) : legacySidebarEnabled ? ( + + ) : ( + + )} + {children} diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 76336f1ef1f2..251b07688121 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -16,7 +16,9 @@ import { resolveLocalCheckoutBranchMismatch, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, + sanitizeNewRefName, shouldIncludeBranchPickerItem, + shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -421,6 +423,38 @@ describe("shouldShowEnvironmentIndicator", () => { }); }); +describe("shouldShowComposerContextStrip", () => { + it("keeps the environment indicator visible for a non-Git project", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: true, + }), + ).toBe(true); + }); + + it("hides the strip when a non-Git project has no environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: false, + }), + ).toBe(false); + }); + + it("shows Git controls without requiring an environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: true, + showEnvironmentIndicator: false, + }), + ).toBe(true); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( @@ -696,4 +730,93 @@ describe("shouldIncludeBranchPickerItem", () => { }), ).toBe(false); }); + + // Typing a spaced name must still surface the ref it would have been created + // as, or the picker shows nothing at all for that query. + it("surfaces an existing ref matching the sanitized query", () => { + expect( + shouldIncludeBranchPickerItem({ + itemValue: "new-branch", + normalizedQuery: "new branch", + createBranchItemValue: null, + checkoutPullRequestItemValue: null, + }), + ).toBe(true); + }); + + // A partial query has to reach the ref it would have been created as, so + // searching "hello w" still finds an existing hello-world. + it("surfaces a ref from a partial query containing a space", () => { + expect( + shouldIncludeBranchPickerItem({ + itemValue: "hello-world", + normalizedQuery: "hello w", + createBranchItemValue: null, + checkoutPullRequestItemValue: null, + }), + ).toBe(true); + }); + + it("excludes refs matching neither the raw nor the sanitized query", () => { + expect( + shouldIncludeBranchPickerItem({ + itemValue: "main", + normalizedQuery: "new branch", + createBranchItemValue: null, + checkoutPullRequestItemValue: null, + }), + ).toBe(false); + }); +}); + +// Git rejects ASCII space and the ASCII control characters in ref names, so a +// typed name like "new branch" can only ever fail. Replacing exactly those can +// turn a failing name into a working one without touching a name git already +// accepts, including one holding non-ASCII whitespace such as U+00A0. +describe("sanitizeNewRefName", () => { + it("replaces a space with a dash", () => { + expect(sanitizeNewRefName("new branch")).toBe("new-branch"); + }); + + it("collapses a run of whitespace into a single dash", () => { + expect(sanitizeNewRefName("new branch")).toBe("new-branch"); + }); + + it("trims surrounding whitespace instead of turning it into dashes", () => { + expect(sanitizeNewRefName(" new branch ")).toBe("new-branch"); + }); + + it("replaces tabs, which git rejects just like spaces", () => { + expect(sanitizeNewRefName("new\tbranch")).toBe("new-branch"); + }); + + // git accepts U+00A0, U+2009 and other non-ASCII whitespace in ref names, so + // rewriting them would silently create a ref the user never typed. + it("preserves whitespace that git accepts", () => { + expect(sanitizeNewRefName("new\u00a0branch")).toBe("new\u00a0branch"); + expect(sanitizeNewRefName("new\u2009branch")).toBe("new\u2009branch"); + }); + + it("keeps slashes so nested ref names survive", () => { + expect(sanitizeNewRefName("feature/new thing")).toBe("feature/new-thing"); + }); + + it("preserves case because git ref names are case sensitive", () => { + expect(sanitizeNewRefName("Feature/New Thing")).toBe("Feature/New-Thing"); + }); + + it("leaves an already valid ref name untouched", () => { + expect(sanitizeNewRefName("feature/login")).toBe("feature/login"); + }); + + it("returns an empty string for whitespace-only input", () => { + expect(sanitizeNewRefName(" ")).toBe(""); + }); + + // Scoped deliberately to whitespace: git accepts consecutive dashes, so + // collapsing them would rewrite names the user may have typed on purpose. + it("does not collapse dashes the user typed", () => { + expect(sanitizeNewRefName("new - branch")).toBe("new---branch"); + expect(sanitizeNewRefName("foo--bar")).toBe("foo--bar"); + }); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index d9737f17a323..0a8e07d1958b 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -54,6 +54,14 @@ export function shouldShowEnvironmentIndicator(input: { return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; } +export function shouldShowComposerContextStrip(input: { + hasActiveProject: boolean; + isGitRepo: boolean; + showEnvironmentIndicator: boolean; +}): boolean { + return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator); +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } @@ -235,6 +243,19 @@ export function resolveBranchSelectionTarget(input: { }; } +// Git rejects ASCII space and the ASCII control characters (tab, newline and +// friends) in ref names, so the picker's "Create new ref" entry can only fail +// for a typed name like "new branch". Replacing runs of those with a dash makes +// the name usable without reimplementing check-ref-format: names invalid for +// other reasons still surface the git error. Only the whitespace git actually +// rejects is replaced — git accepts U+00A0 and friends, and rewriting those +// would silently create a ref the user never asked for. Case and existing +// dashes are left alone, since ref names are case sensitive and consecutive +// dashes are valid. +export function sanitizeNewRefName(rawName: string): string { + return rawName.trim().replace(/[ \t\n\r\f\v]+/g, "-"); +} + export function shouldIncludeBranchPickerItem(input: { itemValue: string; normalizedQuery: string; @@ -255,5 +276,18 @@ export function shouldIncludeBranchPickerItem(input: { return true; } - return itemValue.toLowerCase().includes(normalizedQuery); + const lowerItemValue = itemValue.toLowerCase(); + if (lowerItemValue.includes(normalizedQuery)) { + return true; + } + + // A query containing whitespace can only ever match a ref under its sanitized + // name, because that is the name such a ref would have been created with. + // Without this, typing "new branch" hides an existing "new-branch". + const sanitizedQuery = sanitizeNewRefName(normalizedQuery); + return ( + sanitizedQuery.length > 0 && + sanitizedQuery !== normalizedQuery && + lowerItemValue.includes(sanitizedQuery) + ); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3a83f5c9a0ff..5d11cce11fbe 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,7 +9,7 @@ import { HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -44,6 +44,7 @@ import { Separator } from "./ui/separator"; interface BranchToolbarProps { environmentId: EnvironmentId; threadId: ThreadId; + showGitControls: boolean; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; effectiveEnvModeOverride?: EnvMode; @@ -125,7 +126,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); @@ -214,9 +215,168 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ); }); +/** + * Collapse the strip's labels to icons only when the text no longer fits. + * + * Hidden labels stay measurable because their inner text keeps its natural + * width while the outer layout box collapses. This lets every pass recompute + * the expanded width without remembered values that could go stale or latch + * the strip compact. A small hysteresis keeps the boundary from flapping. + */ +const COMPACT_EXPAND_HYSTERESIS_PX = 16; +const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; +const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; +const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]"; + +function useLabelsOverflow(element: HTMLDivElement | null): boolean { + const [overflows, setOverflows] = useState(false); + const pendingControlRectsRef = useRef | null>(null); + const controlAnimationsRef = useRef(new Map()); + // A render-synced mirror instead of useEffectEvent: the compiler memoizes + // the event callback, which left observers reading the first render's null + // element forever. + const stateRef = useRef({ element, overflows }); + stateRef.current = { element, overflows }; + + const measure = useCallback(() => { + const { element: current, overflows: compact } = stateRef.current; + if (!current) return; + const available = current.clientWidth; + if (available === 0) return; + // flex-1 stretches the groups to fill the strip, so their own boxes always + // measure "full". Sum the laid-out content instead, skipping hidden form + // artifacts and other out-of-flow nodes. + const contentWidth = (parent: Element): number => { + const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; + let width = 0; + let counted = 0; + for (const child of parent.children) { + if (!(child instanceof HTMLElement)) continue; + if (child.offsetWidth <= 1) continue; + const position = getComputedStyle(child).position; + if (position === "absolute" || position === "fixed") continue; + width += child.offsetWidth; + counted += 1; + } + return width + gap * Math.max(0, counted - 1); + }; + const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0; + let needed = 0; + let groups = 0; + for (const child of current.children) { + if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; + needed += contentWidth(child); + groups += 1; + } + needed += stripGap * Math.max(0, groups - 1); + for (const label of current.querySelectorAll("[data-composer-label]")) { + // The clipping can happen below the marker (SelectValue truncates + // internally), where the outer span's scrollWidth matches its clipped + // box. The text's real width is the largest scrollWidth in the subtree. + let textWidth = label.scrollWidth; + for (const inner of label.querySelectorAll("*")) { + textWidth = Math.max(textWidth, inner.scrollWidth); + } + if (compact) { + // Compact: the label is squeezed to zero width but keeps reporting + // the full width it would need when expanded. + needed += textWidth; + } else { + // Expanded: the label is in flow; only the clipped remainder is + // missing from the content sum. + needed += Math.max(0, textWidth - label.clientWidth); + } + } + const nextOverflows = compact + ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX + : needed > available; + if (nextOverflows !== compact) { + pendingControlRectsRef.current = new Map( + Array.from(current.querySelectorAll(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map( + (control) => [control, control.getBoundingClientRect()], + ), + ); + } + setOverflows(nextOverflows); + }, []); + + useLayoutEffect(() => { + const previousRects = pendingControlRectsRef.current; + if (!previousRects) return; + pendingControlRectsRef.current = null; + + for (const animation of controlAnimationsRef.current.values()) { + animation.cancel(); + } + controlAnimationsRef.current.clear(); + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + for (const [control, previousRect] of previousRects) { + if (!control.isConnected) continue; + const nextRect = control.getBoundingClientRect(); + const deltaX = previousRect.left - nextRect.left; + const deltaY = previousRect.top - nextRect.top; + if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) continue; + + const animation = control.animate( + [ + { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` }, + { transform: "translate3d(0, 0, 0)" }, + ], + { + duration: COMPOSER_CONTEXT_MOTION_DURATION_MS, + easing: COMPOSER_CONTEXT_MOTION_EASING, + fill: "backwards", + }, + ); + controlAnimationsRef.current.set(control, animation); + animation.addEventListener( + "finish", + () => { + if (controlAnimationsRef.current.get(control) === animation) { + controlAnimationsRef.current.delete(control); + } + }, + { once: true }, + ); + } + }, [overflows]); + + useEffect( + () => () => { + for (const animation of controlAnimationsRef.current.values()) { + animation.cancel(); + } + }, + [], + ); + + // Label widths can change without the strip box moving (font family or + // size preferences), so re-measure on every render as well as on resize + // and font loads. + useEffect(() => { + measure(); + }); + + useEffect(() => { + if (!element) return; + const observer = new ResizeObserver(measure); + observer.observe(element); + document.fonts.addEventListener("loadingdone", measure); + return () => { + observer.disconnect(); + document.fonts.removeEventListener("loadingdone", measure); + }; + }, [element, measure]); + + return overflows; +} + export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, + showGitControls, draftId, onEnvModeChange, effectiveEnvModeOverride, @@ -300,12 +460,18 @@ export const BranchToolbar = memo(function BranchToolbar({ canPickEnvironment: showEnvironmentPicker, }); const isMobile = useIsMobile(); + const [stripElement, setStripElement] = useState(null); + const labelsOverflow = useLabelsOverflow(stripElement); if (!hasActiveThread || !activeProject) return null; return ( -
- {isMobile ? ( +
+ {isMobile && showGitControls ? ( - + {showGitControls ? ( + + ) : null} )} - + {showGitControls ? ( + + ) : null}
)} - + {showGitControls ? ( + + ) : null}
); }); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 9b4cbf2b4a41..5fcad2f741db 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -42,6 +42,7 @@ import { resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, + sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { @@ -51,6 +52,7 @@ import { } from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; import { Switch } from "./ui/switch"; +import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; import { Combobox, ComboboxEmpty, @@ -219,13 +221,18 @@ export function BranchToolbarBranchSelector({ ); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); + // The server filters refs by substring, so it has to be given the sanitized + // name as well: querying the raw "new branch" drops an existing new-branch + // from the response entirely, which would defeat the collision check below. + // Ref names cannot contain an ASCII space, so sanitizing loses no matches. + const branchRefQuery = sanitizeNewRefName(deferredTrimmedBranchQuery); const branchRefTarget = useMemo( () => ({ environmentId, cwd: branchCwd, - query: deferredTrimmedBranchQuery, + query: branchRefQuery, }), - [branchCwd, deferredTrimmedBranchQuery, environmentId], + [branchCwd, branchRefQuery, environmentId], ); const branchRefState = usePaginatedBranches(branchRefTarget); const refs = branchRefState.refs; @@ -258,7 +265,11 @@ export function BranchToolbarBranchSelector({ const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; - const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); + // The ref is created under its sanitized name, so the collision check has to + // use that name too. Matching on the raw query would offer to create a ref + // that already exists whenever sanitizing changes the name. + const newRefName = sanitizeNewRefName(trimmedBranchQuery); + const hasExactBranchMatch = branchByName.has(newRefName); const createBranchItemValue = canCreateBranch ? `__create_new_branch__:${trimmedBranchQuery}` : null; @@ -440,7 +451,7 @@ export function BranchToolbarBranchSelector({ }; const createRef = (rawName: string) => { - const name = rawName.trim(); + const name = sanitizeNewRefName(rawName); if (!branchCwd || !name || isBranchActionPending) return; setIsBranchMenuOpen(false); @@ -613,9 +624,9 @@ export function BranchToolbarBranchSelector({ // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's // state-description tooltip. const branchPrTooltip = branchPr - ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state}) in browser` + ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})` : ""; - const openPrLink = useOpenPrLink(); + const openPrLink = useOpenPrLink(threadRef); function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { @@ -658,7 +669,7 @@ export function BranchToolbarBranchSelector({ className="pe-1.5" onClick={() => createRef(trimmedBranchQuery)} > - Create new ref "{trimmedBranchQuery}" + Create new ref "{newRefName}" ); } @@ -714,7 +725,10 @@ export function BranchToolbarBranchSelector({ open={isBranchMenuOpen} value={resolvedActiveBranch} > -
+
{branchPr && branchPrStatus ? ( - {triggerLabel} + + + {triggerLabel} + + @@ -801,9 +825,11 @@ export function BranchToolbarBranchSelector({ maybeFetchNextBranchPage(); }} className={cn( - "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1 [--fade-size:1.5rem]", - showTopBranchScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", - showBottomBranchScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", + "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1", + getVirtualizedScrollFadeClassName({ + top: showTopBranchScrollFade, + bottom: showBottomBranchScrollFade, + }), )} style={{ maxHeight: "14rem" }} /> diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index d300139d3cf5..9fc2d4892e27 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -50,7 +50,10 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( - + {activeWorktreePath ? ( <> @@ -82,8 +85,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {effectiveEnvMode === "worktree" ? ( @@ -92,7 +96,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : ( )} - + + + + + diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index e4ed54758ff4..b5d5751a280b 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -41,15 +41,33 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir [availableEnvironments], ); + // The static label carries the xs control's height (h-7 sm:h-6) as well as + // its padding: the composer context strip has no min-height of its own, and + // the glass seam joining it to the composer assumes a fixed strip height, so + // a shorter label would drag the seam out of line whenever this label is the + // only thing in the strip. if (envLocked || onEnvironmentChange === undefined) { return ( - + {activeEnvironment?.isPrimary ? ( ) : ( )} - {activeEnvironment?.label ?? "Run on"} + + + {activeEnvironment?.label ?? "Run on"} + + ); } @@ -66,13 +84,24 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir size="xs" className="min-w-0 max-w-full font-medium" aria-label="Run on" + data-composer-context-control > {activeEnvironment?.isPrimary ? ( ) : ( )} - + + + + + diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx new file mode 100644 index 000000000000..9499ee5a6915 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { orderedListGutterStyle } from "./ChatMarkdown"; + +describe("orderedListGutterStyle", () => { + it("leaves the default gutter alone for single-digit lists", () => { + expect(orderedListGutterStyle(9, undefined)).toBeUndefined(); + }); + + it("leaves the default gutter alone for two-digit lists", () => { + expect(orderedListGutterStyle(99, undefined)).toBeUndefined(); + }); + + it("leaves the default gutter alone for a two-digit list that starts above 1", () => { + // start=50 + 49 items => last marker is "98", still two digits. + expect(orderedListGutterStyle(49, 50)).toBeUndefined(); + }); + + it("widens the gutter once the last marker reaches three digits", () => { + // item 100 is the bug from #6512: a 100-item list starting at 1. + expect(orderedListGutterStyle(100, undefined)).toEqual({ "--list-gutter": "4ch" }); + }); + + it("accounts for a non-default start attribute", () => { + // start=95 + 9 items => last marker is "103", three digits. + expect(orderedListGutterStyle(9, 95)).toEqual({ "--list-gutter": "4ch" }); + }); + + it("scales further for four-digit markers", () => { + expect(orderedListGutterStyle(1000, undefined)).toEqual({ "--list-gutter": "5ch" }); + }); + + it("treats a missing/zero item count as a single item", () => { + expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 985e943cb39c..c4548540e2ce 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -4,8 +4,13 @@ import { ChevronRightIcon, CopyIcon, GlobeIcon, + InfoIcon, + LightbulbIcon, Maximize2Icon, + MessageSquareWarningIcon, Minimize2Icon, + OctagonAlertIcon, + TriangleAlertIcon, WrapTextIcon, } from "lucide-react"; import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; @@ -38,6 +43,7 @@ import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; +import { remarkGithubAlerts } from "../markdown-github-alerts"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; @@ -52,6 +58,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { recordVisitForThread } from "../browserHistoryStore"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; @@ -83,6 +90,14 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { projectEnvironment } from "../state/projects"; +import { + claimWorkspaceBasenameLookup, + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, + WORKSPACE_BASENAME_LOOKUP_LIMIT, +} from "../workspaceBasenameLookup"; +import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { @@ -102,6 +117,8 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + /** Parse sanitized raw HTML instead of displaying its source text. */ + parseRawHtml?: boolean; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -138,12 +155,33 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb if (!match?.[1]) return null; return listItemStart + firstLine.indexOf(match[1]); } + +/** + * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits two-digit + * decimal markers. Once a list's last item reaches three digits (item 100+), + * `list-style-position: outside` paints the marker wider than that gutter and + * the leading digit gets clipped by the item's own overflow. Rather than + * widening the gutter for every list, only lists whose last marker is 3+ + * digits get a wider `--list-gutter`, sized to that marker's digit count. + */ +export function orderedListGutterStyle( + itemCount: number, + start: number | undefined, +): { "--list-gutter": string } | undefined { + const firstNumber = typeof start === "number" && Number.isFinite(start) ? start : 1; + const lastNumber = firstNumber + Math.max(itemCount - 1, 0); + const digits = String(Math.abs(lastNumber)).length; + if (digits <= 2) return undefined; + return { "--list-gutter": `${digits + 1}ch` }; +} + const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], + blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], }, protocols: { ...defaultSchema.protocols, @@ -153,6 +191,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, + remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, remarkTagInlineCode, @@ -160,6 +199,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, + remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, @@ -171,6 +211,43 @@ const CHAT_MARKDOWN_REHYPE_PLUGINS = [ [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; +/** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ +const GITHUB_ALERT_PRESENTATIONS: Record< + string, + { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } +> = { + note: { + label: "Note", + Icon: InfoIcon, + borderClassName: "border-blue-500/70", + titleClassName: "text-blue-600 dark:text-blue-400", + }, + tip: { + label: "Tip", + Icon: LightbulbIcon, + borderClassName: "border-emerald-500/70", + titleClassName: "text-emerald-600 dark:text-emerald-400", + }, + important: { + label: "Important", + Icon: MessageSquareWarningIcon, + borderClassName: "border-purple-500/70", + titleClassName: "text-purple-600 dark:text-purple-400", + }, + warning: { + label: "Warning", + Icon: TriangleAlertIcon, + borderClassName: "border-amber-500/70", + titleClassName: "text-amber-600 dark:text-amber-500", + }, + caution: { + label: "Caution", + Icon: OctagonAlertIcon, + borderClassName: "border-red-500/70", + titleClassName: "text-red-600 dark:text-red-400", + }, +}; + function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; @@ -401,7 +478,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { {children} -
+
-
- +
+ - + Promise>; + onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; onOpenInBrowser?: (() => Promise>) | undefined; className?: string | undefined; } @@ -849,7 +927,10 @@ const failedFaviconHosts = new Set(); const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( - + {failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( @@ -904,6 +985,25 @@ function plainHastText(node: unknown): string | null { return parts.every((part) => part !== null) ? parts.join("") : null; } +/** + * Whether the link carries any words of its own. An anchor that is only an image — a badge, a + * "Fix in Cursor" button — already shows its identity, and a favicon bolted on in front of it + * is a stray logo rather than a hint. + */ +function hastHasText(node: unknown): boolean { + if (!node || typeof node !== "object") return false; + if ( + "type" in node && + node.type === "text" && + "value" in node && + typeof node.value === "string" && + node.value.trim().length > 0 + ) { + return true; + } + return "children" in node && Array.isArray(node.children) && node.children.some(hastHasText); +} + const SANITIZED_FRAGMENT_PREFIX = "user-content-"; function decodeMarkdownFragmentId(href: string): string { @@ -977,7 +1077,7 @@ function MarkdownExternalLinkContent({ const leadingLength = leadingExternalLinkTextLength(plainText); return ( <> - + {plainText.slice(0, leadingLength)} @@ -993,7 +1093,7 @@ function MarkdownExternalLinkContent({ const leadingLength = leadingExternalLinkTextLength(firstChild); return ( <> - + {firstChild.slice(0, leadingLength)} @@ -1005,7 +1105,7 @@ function MarkdownExternalLinkContent({ return ( <> - + {firstChild} @@ -1026,6 +1126,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ theme, threadRef, onOpen, + onOpenInPanel, onOpenInBrowser, className, }: MarkdownFileLinkProps) { @@ -1069,8 +1170,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInEditor(); return; } - useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line); - }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); + onOpenInPanel(workspaceRelativePath, line); + }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1222,7 +1323,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ side="top" className="max-w-[min(40rem,calc(100vw-2rem))] font-mono text-[11px] leading-tight" > -
+
{displayPath}
@@ -1246,6 +1347,7 @@ function areMarkdownFileLinkPropsEqual( previous.theme === next.theme && previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && + previous.onOpenInPanel === next.onOpenInPanel && previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); @@ -1260,11 +1362,15 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + parseRawHtml = true, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); + const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { + reportFailure: false, + }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); @@ -1323,6 +1429,7 @@ function ChatMarkdown({ event.clipboardData.setData("text/plain", payload.text); event.clipboardData.setData("text/html", payload.html); }, []); + const openChangeRequestLink = useOpenChangeRequestLink(threadRef); const openExternalLinkInPreview = useCallback( (url: string) => { if (!threadRef) { @@ -1336,7 +1443,10 @@ function ChatMarkdown({ ), ); } - return openUrlInPreview({ threadRef, url, openPreview }); + return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { + if (result._tag === "Success") recordVisitForThread(threadRef, url); + return result; + }); }, [openPreview, threadRef], ); @@ -1363,6 +1473,43 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + // A bare filename resolves to the workspace root, which is rarely where the + // file is, so ask the index before opening. + const openFileInPanel = useCallback( + (workspaceRelativePath: string, line: number | undefined) => { + if (!threadRef) return; + // Claimed on every open so a synchronous one supersedes a lookup already + // in flight. + const isLatestLookup = claimWorkspaceBasenameLookup(); + const openAt = (path: string) => + useRightPanelStore.getState().openFile(threadRef, path, line); + if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + openAt(workspaceRelativePath); + return; + } + void (async () => { + const result = await searchProjectEntries({ + environmentId: threadRef.environmentId, + input: { + cwd, + query: workspaceRelativePath, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + const match = + result._tag === "Success" + ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + : null; + if (!isLatestLookup()) return; + openAt(match ?? workspaceRelativePath); + })(); + }, + [cwd, searchProjectEntries, threadRef], + ); + /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component + * renderers that close over this message's metadata. useMemo keeps them stable until that + * metadata changes. */ const markdownComponents = useMemo(() => { const fileLinkChip = ( fileLinkMeta: MarkdownFileLinkMeta, @@ -1393,6 +1540,7 @@ function ChatMarkdown({ theme={resolvedTheme} threadRef={threadRef} onOpen={openInPreferredEditor} + onOpenInPanel={openFileInPanel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -1409,6 +1557,35 @@ function ChatMarkdown({ p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, + blockquote({ node: _node, children, ...props }) { + const alert = + GITHUB_ALERT_PRESENTATIONS[ + String((props as Record)["data-alert"] ?? "") + ]; + if (!alert) { + return
{children}
; + } + // Not a
: the stylesheet mutes those, and an alert's body is ordinary + // text under a colored title — which is how the host renders it. + return ( +
+

+ + {alert.label} +

+ {children} +
+ ); + }, + ol({ node, start, style, ...props }) { + const itemCount = + node?.children?.filter((child) => child.type === "element" && child.tagName === "li") + .length ?? 0; + const gutterStyle = orderedListGutterStyle(itemCount, start); + return ( +
    + ); + }, li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = @@ -1448,7 +1625,7 @@ function ChatMarkdown({ /> ); }, - a({ node, href, children, ...props }) { + a({ node, href, children, title: _title, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { @@ -1466,16 +1643,23 @@ function ChatMarkdown({ onClick?.(event); if (isSameDocumentLink && href) { handleMarkdownFragmentClick(event, href); + return; } + // A link to a change request in a workspace project opens beside the + // conversation instead of in a browser: it is the thing being talked about, and + // the panel it opens offers the browser as one of its actions. Anything else is + // an ordinary link and keeps the `_blank` the shell already handles. + if (href) openChangeRequestLink(event, href); }} onContextMenu={(event) => { - if (!canOpenInPreview || !href || !faviconHost) return; + if (!href || !faviconHost) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; void showExternalLinkContextMenu({ href, + canOpenInPreview, position: { x: event.clientX, y: event.clientY }, showContextMenu: (items, position) => api.contextMenu.show(items, position), openInPreview: async (target) => { @@ -1495,7 +1679,7 @@ function ChatMarkdown({ }); }} > - {faviconHost ? ( + {faviconHost && hastHasText(node) ? ( {children} @@ -1526,6 +1710,9 @@ function ChatMarkdown({ props.className, ); }, + img({ node: _node, title: _title, ...props }) { + return ; + }, code({ node, children, className, ...props }) { if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); @@ -1585,6 +1772,7 @@ function ChatMarkdown({ isStreaming, markdownFileLinkMetaByHref, onTaskListChange, + openFileInPanel, openInPreferredEditor, openExternalLinkInPreview, openMarkdownFileInPreview, @@ -1593,11 +1781,15 @@ function ChatMarkdown({ text, threadRef, ]); + /* eslint-enable react/no-unstable-nested-components */ + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. + // Keep that behavior explicit because literal mode depends on escaping the + // complete source token instead of dropping it from the rendered message. return (
    diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 39285438d1af..5c026c94a138 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -6,7 +6,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types"; import { @@ -19,13 +19,16 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, + hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + scheduleEnvironmentReconnectWarning, startNewThreadForProject, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, @@ -36,6 +39,42 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("environment reconnect warning grace", () => { + afterEach(() => vi.useRealTimers()); + + it("shows a persistent reconnect after the grace period", () => { + vi.useFakeTimers(); + const showWarning = vi.fn(); + + scheduleEnvironmentReconnectWarning(showWarning); + vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); + expect(showWarning).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(showWarning).toHaveBeenCalledOnce(); + }); + + it("cancels the warning when the connection recovers during the grace period", () => { + vi.useFakeTimers(); + const showWarning = vi.fn(); + + const cancel = scheduleEnvironmentReconnectWarning(showWarning); + cancel(); + vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); + + expect(showWarning).not.toHaveBeenCalled(); + }); + + it("does not reuse elapsed grace from another environment", () => { + const anotherEnvironmentId = EnvironmentId.make("environment-remote"); + + expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); + expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( + false, + ); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: threadId, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04b35fd45516..04561b507c3e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -25,12 +25,25 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; +export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { + const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); + return () => globalThis.clearTimeout(timeoutId); +} + +export function hasEnvironmentReconnectWarningGraceElapsed( + activeEnvironmentId: EnvironmentId | null, + elapsedEnvironmentId: EnvironmentId | null, +): boolean { + return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId; +} + export function startNewThreadForProject( projectRef: ScopedProjectRef | null, - handleNewThread: (projectRef: ScopedProjectRef) => Promise, + handleNewThread: (projectRef: ScopedProjectRef) => Promise, ): boolean { if (projectRef === null) return false; void handleNewThread(projectRef); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8f737b36ac38..674542f3ee0e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8,6 +8,7 @@ import { type ProjectScript, type ProjectId, type ProviderApprovalDecision, + type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, type ResolvedKeybindingsConfig, @@ -25,7 +26,12 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; -import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + changeRequestAutoSettles, + effectiveSettled, + effectiveSnoozed, + threadWokeAt, +} from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -80,7 +86,7 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, - findSidebarProposedPlan, + deriveTurnPlans, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, @@ -122,6 +128,7 @@ import { selectActiveRightPanelSurface, selectThreadRightPanelState, type RightPanelSurface, + updatePullRequestTabStatus, useRightPanelStore, } from "../rightPanelStore"; import { @@ -134,22 +141,31 @@ import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; -import { RightPanelTabs } from "./RightPanelTabs"; +import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; +import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; +import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; +import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; +import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; +import { AgentsPanel } from "./AgentsPanel"; +import { + deriveAgentPanelModel, + foldSubagentActivities, +} from "@t3tools/client-runtime/state/subagentRuntime"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, - TriangleAlertIcon, + PaperclipIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; @@ -164,18 +180,27 @@ import { projectScriptIdFromCommand, } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { useBrowserHistoryStore } from "~/browserHistoryStore"; +import { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { + useClientSettings, + useClientSettingsHydrated, + useEnvironmentSettings, +} from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { + derivePhysicalProjectKey, deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -208,14 +233,17 @@ import { serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; -import { threadEnvironment } from "../state/threads"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { + requestOlderThreadTurns, + threadHasOlderTurns, +} from "@t3tools/client-runtime/state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { useProject, useProjects, useThread, - useThreadProposedPlans, useThreadRefs, useThreadShell, } from "../state/entities"; @@ -225,18 +253,33 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { + resolveEffectiveEnvMode, + resolveLocalCheckoutBranchMismatch, + shouldShowComposerContextStrip, + shouldShowEnvironmentIndicator, +} from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, ProviderStatusBanner, shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; -import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; -import { resolveThreadPr } from "./ThreadStatusIndicators"; +import { + dismissThreadErrorBannerForSession, + getThreadErrorBannerKey, + isThreadErrorBannerDismissedForSession, + shouldShowThreadErrorBanner, + ThreadErrorBanner, +} from "./chat/ThreadErrorBanner"; +import { + resolveDisplayedThreadPr, + threadChangeRequestSnapshotsAtom, +} from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { @@ -258,6 +301,8 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + hasEnvironmentReconnectWarningGraceElapsed, + scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, @@ -426,8 +471,6 @@ type EnvironmentUnavailableState = { readonly connection: EnvironmentConnectionPresentation; }; -type ThreadPlanCatalogEntry = Pick; - function eventPathContainsSelector(event: Event, selector: string): boolean { const path = event.composedPath(); if (path.length === 0 && event.target) { @@ -445,6 +488,14 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; + // The right-panel surface launcher claims its shortcut letters while it is + // visible (data attribute set in RightPanelTabs); those keys open surfaces + // instead of typing into the composer. + const launcherKeys = document + .querySelector("[data-surface-launcher-keys]") + ?.getAttribute("data-surface-launcher-keys"); + if (launcherKeys && launcherKeys.toLowerCase().includes(event.key.toLowerCase())) return false; + return true; } @@ -704,6 +755,19 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra () => drawerTerminalSessions.map((session) => session.target.terminalId), [drawerTerminalSessions], ); + // Every client-side id source participates in allocation: the server list + // lags fresh opens, and panel terminals are filtered out of the drawer's + // sessions — an id collision attaches two viewports to one PTY session. + const allocatableTerminalIds = useMemo( + () => [ + ...new Set([ + ...serverOrderedTerminalIds, + ...terminalUiState.terminalIds, + ...panelTerminalIds, + ]), + ], + [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], + ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore( @@ -773,7 +837,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeSplitTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -787,11 +851,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, }); }, [ + allocatableTerminalIds, bumpFocusRequestId, cwd, effectiveWorktreePath, runtimeEnv, - serverOrderedTerminalIds, storeSplitTerminal, threadId, threadRef, @@ -801,7 +865,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeSplitTerminalVertical(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -815,12 +879,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, }); }, [ + allocatableTerminalIds, bumpFocusRequestId, cwd, effectiveWorktreePath, openTerminal, runtimeEnv, - serverOrderedTerminalIds, storeSplitTerminalVertical, threadId, threadRef, @@ -830,7 +894,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeNewTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -847,8 +911,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, + allocatableTerminalIds, runtimeEnv, - serverOrderedTerminalIds, storeNewTerminal, threadId, threadRef, @@ -1206,10 +1270,24 @@ function ChatViewContent(props: ChatViewProps) { [routeServerThreadShell, threadDetailLoading], ); const activeServerThread = serverThread ?? loadingServerThread; - const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[routeThreadKey], + // Pagination window state for the routed server thread: drives the + // "load earlier turns" header when the loaded window has older history. + const routeThreadState = useEnvironmentThread( + routeKind === "server" ? routeThreadRef.environmentId : null, + routeKind === "server" ? routeThreadRef.threadId : null, ); + const loadEarlierTurns = useMemo(() => { + if (routeKind !== "server" || !threadHasOlderTurns(routeThreadState)) { + return null; + } + return { + loading: routeThreadState.page._tag === "Some" && routeThreadState.page.value.loadingOlder, + onLoadEarlier: () => { + requestOlderThreadTurns(routeThreadRef.environmentId, routeThreadRef.threadId); + }, + }; + }, [routeKind, routeThreadRef, routeThreadState]); + const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the // settings UI never writes to remote environments), so read them from the @@ -1219,7 +1297,6 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; - const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. @@ -1264,6 +1341,7 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -1284,17 +1362,23 @@ function ChatViewContent(props: ChatViewProps) { const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); + + useEffect(() => { + setIsWorkspaceFileDragActive(false); + }, [draftId, routeThreadKey]); + + useEffect(() => { + if (!isWorkspaceFileDragActive) return; + const clearWorkspaceFileDrag = () => setIsWorkspaceFileDragActive(false); + window.addEventListener("dragend", clearWorkspaceFileDrag); + return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); + }, [isWorkspaceFileDragActive]); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record> >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); - // Tracks whether the user explicitly dismissed the sidebar for the active turn. - const planSidebarDismissedForTurnRef = useRef(null); - // When set, the thread-change reset effect will open the sidebar instead of closing it. - // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. - const planSidebarOpenOnNextThreadRef = useRef(false); + const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -1443,12 +1527,36 @@ function ChatViewContent(props: ChatViewProps) { const threadError = isServerThread ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; + // Dismissals can only mask the shown error, never clear it: a server thread + // keeps its error in session.lastError, so clearing the local shadow would + // just fall through to the persisted one. Mask the current error until a + // different error arrives, mirroring the provider status banner. + const threadErrorBannerKey = getThreadErrorBannerKey(routeThreadKey, threadError); + const visibleThreadError = shouldShowThreadErrorBanner( + routeThreadKey, + threadError, + isThreadErrorBannerDismissedForSession(threadErrorBannerKey), + ) + ? threadError + : null; + // Dismissing only mutates the session-scoped mask set, which does not + // trigger a render on its own; setThreadError(null) can also bail when the + // local shadow is already empty and the banner is driven purely by + // session.lastError. Bump a tick so the banner hides immediately. Mirrors + // the branch mismatch banner. + const [, setThreadErrorBannerDismissTick] = useState(0); const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = - composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; + // Plan mode is legacy (Settings → Beta). With the flag off the effective + // mode is forced to "default" — even for threads with a stored plan mode — + // so nobody is trapped in plan mode while its toggle is hidden. The next + // send persists "default" back to the thread. + const interactionMode = settings.planModeEnabled + ? (composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE) + : DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; + const activeThreadEnvironmentId = activeThread?.environmentId ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, @@ -1484,10 +1592,14 @@ function ChatViewContent(props: ChatViewProps) { return labels; }, [activeThreadKnownSessions]); const activeThreadRef = useMemo( - () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), - [activeThread], + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -1506,6 +1618,21 @@ function ChatViewContent(props: ChatViewProps) { const activeRightPanelSurface = useRightPanelStore((state) => selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); + const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState< + Record + >({}); + // Keyed by the surface the panel is showing rather than by a key rebuilt from the status, so + // the tab is found again whether or not that surface was opened with an environment on it. + const activePullRequestSurfaceId = + activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; + const handlePullRequestTabStatusChange = useCallback( + (status: PullRequestTabStatus) => { + const id = activePullRequestSurfaceId; + if (id === undefined) return; + setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status)); + }, + [activePullRequestSurfaceId], + ); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); @@ -1521,12 +1648,16 @@ function ChatViewContent(props: ChatViewProps) { ), [rightPanelState.surfaces], ); + const allocatableActiveTerminalIds = useMemo( + () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], + [activeKnownTerminalIds, panelTerminalIds], + ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; - const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; + const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; - const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; useEffect(() => { if (!activeThreadRef) return; @@ -1553,36 +1684,29 @@ function ChatViewContent(props: ChatViewProps) { previewPanelOpen, ]); - const planSidebarOpen = activeRightPanelKind === "plan"; - const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const sourcePlanThreadRef = useMemo(() => { - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { - return null; - } - return scopeThreadRef(activeThread.environmentId, sourceThreadId); - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); - const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); - const threadPlanCatalog = useMemo(() => { - if (!activeThread) { - return []; - } - const entries: ThreadPlanCatalogEntry[] = [ - { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, - ]; - if (sourcePlanThreadRef) { - entries.push({ - id: sourcePlanThreadRef.threadId, - proposedPlans: sourceThreadProposedPlans, - }); - } - return entries; - }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); + // Reading a finished thread clears the sidebar's Done badge. The visit is + // stamped at the turn's completion time — not now/updatedAt — so it clears + // exactly the completion the user is looking at: a wake or completion that + // lands later still gets its signal (markThreadVisited never moves the + // timestamp backwards). + useEffect(() => { + const completedAt = serverThread?.latestTurn?.completedAt; + if (!serverThread?.id || !completedAt) return; + markThreadVisited( + scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), + completedAt, + ); + }, [ + markThreadVisited, + serverThread?.environmentId, + serverThread?.id, + serverThread?.latestTurn?.completedAt, + ]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ @@ -1599,9 +1723,11 @@ function ChatViewContent(props: ChatViewProps) { }); }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProjectRef = activeThread - ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) - : null; + const activeProjectRef = useMemo( + () => + activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null, + [activeThread?.environmentId, activeThread?.projectId], + ); const activeProject = useProject(activeProjectRef); const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); @@ -1613,6 +1739,8 @@ function ChatViewContent(props: ChatViewProps) { const activeProjectKey = activeProject ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const clientSettingsHydrated = useClientSettingsHydrated(); const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< ReadonlyMap> >(() => new Map()); @@ -1651,11 +1779,58 @@ function ChatViewContent(props: ChatViewProps) { // drive the environment picker in BranchToolbar. const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + useEffect(() => { + if (!activeThreadRef || !activeProjectRef) return; + registerFaviconProjectForThread(activeThreadRef, activeProjectRef); + }, [activeProjectRef, activeThreadRef]); + useEffect(() => { + if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; + // Reuse the sidebar's grouping so history follows the project rows the user + // sees. Deriving the key from the active project alone would miss the + // identity a duplicate row borrows from its siblings. + const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({ + projects: allProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }); + useBrowserHistoryStore + .getState() + .registerThreadProject( + activeThreadRef, + logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ?? + deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings), + ); + }, [ + activeProject, + activeThreadRef, + allProjects, + clientSettingsHydrated, + primaryEnvironmentId, + projectGroupingSettings, + ]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; + const activeReconnectingEnvironmentId = + activeEnvironmentConnectionPhase === "connecting" || + activeEnvironmentConnectionPhase === "reconnecting" + ? (activeEnvironment?.environmentId ?? null) + : null; + const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = + useState(null); + const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( + activeReconnectingEnvironmentId, + reconnectWarningGraceElapsedEnvironmentId, + ); + useEffect(() => { + setReconnectWarningGraceElapsedEnvironmentId(null); + if (activeReconnectingEnvironmentId === null) return; + return scheduleEnvironmentReconnectWarning(() => + setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), + ); + }, [activeReconnectingEnvironmentId]); const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = useMemo(() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { @@ -1684,7 +1859,6 @@ function ChatViewContent(props: ChatViewProps) { }, [retryEnvironment], ); - const projectGroupingSettings = selectProjectGroupingSettings(settings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -1718,6 +1892,14 @@ function ChatViewContent(props: ChatViewProps) { return envs; }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const activeEnvironmentOption = + logicalProjectEnvironments.find( + (environment) => environment.environmentId === activeThread?.environmentId, + ) ?? null; + const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: hasMultipleEnvironments, + }); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -1824,25 +2006,6 @@ function ChatViewContent(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); - useEffect(() => { - if (!serverThread?.id) return; - const threadUpdatedAt = Date.parse(serverThread.updatedAt); - if (Number.isNaN(threadUpdatedAt)) return; - const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; - - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - serverThread.updatedAt, - ); - }, [ - activeThreadLastVisitedAt, - markThreadVisited, - serverThread?.environmentId, - serverThread?.id, - serverThread?.updatedAt, - ]); - const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? @@ -1858,6 +2021,8 @@ function ChatViewContent(props: ChatViewProps) { const serverConfig = activeThread ? (activeEnvironment?.serverConfig ?? null) : (primaryEnvironment?.serverConfig ?? null); + const pullRequestsCapabilityKnown = serverConfig !== null; + const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -1900,12 +2065,16 @@ function ChatViewContent(props: ChatViewProps) { // While an update runs, transient connect blips are expected (the server // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. - const suppressUnavailableBanner = updateRunning && environmentReconnecting; + const suppressUnavailableBanner = + environmentReconnecting && + (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed)); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { if (reconnectingThroughVersionSkew) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: "default", + // Live connection status: calm styling, but it must front the stack. + urgent: true, icon: (