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 98c1c3b20224..fbcd52e697dd 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -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 9bc321dac0de..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 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 3c9424fb322c..000000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,102 +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 - - apps/mobile - - 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 3eaaf508e31f..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 @@ -62,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() @@ -80,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: @@ -137,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 a9754f9421b5..6abd702bf889 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -746,7 +746,7 @@ 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: @@ -856,6 +856,16 @@ jobs: fail_on_unmatched_files: true 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 needs: [preflight, relay_public_config, release] 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" ? ( @@ -314,18 +310,32 @@ function WorkflowScriptView({ } /** - * Collapsible phase section (Claude Code Background-tasks pattern): live - * phases open by default, done phases collapsed to header + member dot row. - * User toggles override the default and stick for the phase's lifetime. + * 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 }: { phase: AgentPanelWorkflowGroup["phases"][number] }) { - const [userOpen, setUserOpen] = useState(null); - const open = userOpen ?? phase.state === "running"; +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 (
{scriptOpen && canShowScript ? ( @@ -416,7 +438,7 @@ function LiveWorkflowSection({ /> ) : null} {group.phases.map((phase) => ( - + ))} {group.unphasedMembers.map((member) => ( @@ -429,11 +451,16 @@ function LiveWorkflowSection({ } /** - * Settled workflow: one summary line. Click toggles the member list — the - * only expansion in the panel, at run granularity. + * Collapsed workflow: one summary line. The parent owns expansion so a live + * workflow keeps its shape when it settles. */ -function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) { - const [open, setOpen] = useState(false); +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): @@ -450,9 +477,9 @@ function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {
- {open ? ( -
- {members.map((member) => ( - - ))} -
- ) : null}
); } +/** 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, @@ -503,48 +542,24 @@ export function AgentsPanel({ ); } - const liveWorkflows = model.workflows.filter(workflowIsLive); - const settledWorkflows = model.workflows.filter((group) => !workflowIsLive(group)); - const liveDirect = model.directAgents.filter( - (agent) => - agent.status === "running" || agent.status === "pending" || agent.status === "waiting", - ); - const settledDirect = model.directAgents.filter( - (agent) => - agent.status !== "running" && agent.status !== "pending" && agent.status !== "waiting", - ); - return (
- {liveWorkflows.map((group) => ( - ( + ))} - {liveDirect.length > 0 ? ( + {model.directAgents.length > 0 ? (
Direct spawns
- {liveDirect.map((agent) => ( - - ))} -
- ) : null} - {settledWorkflows.length > 0 || settledDirect.length > 0 ? ( -
-
- Earlier -
- {settledWorkflows.map((group) => ( - - ))} - {settledDirect.map((agent) => ( + {model.directAgents.map((agent) => ( ))}
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 a3f043c65368..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, useEffect, useMemo, useRef, useState } 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} ); @@ -217,16 +218,20 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ /** * Collapse the strip's labels to icons only when the text no longer fits. * - * Hidden labels stay measurable (they collapse to invisible absolute boxes, - * which keep their natural width), so the required width can be recomputed in - * either state on every pass - no remembered widths that could go stale or - * latch the strip compact. A small hysteresis keeps the boundary from - * flapping between states. + * 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. @@ -240,7 +245,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { 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 absolutely-positioned nodes (the compact-hidden labels). + // artifacts and other out-of-flow nodes. const contentWidth = (parent: Element): number => { const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; let width = 0; @@ -282,9 +287,71 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { needed += Math.max(0, textWidth - label.clientWidth); } } - setOverflows(compact ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX : needed > available); + 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. @@ -309,6 +376,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, + showGitControls, draftId, onEnvModeChange, effectiveEnvModeOverride, @@ -401,9 +469,9 @@ export const BranchToolbar = memo(function BranchToolbar({
- {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 bbd27f65ab0d..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} + @@ -806,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 ca778daad31c..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 ? ( <> @@ -84,6 +87,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe size="xs" className="min-w-0 shrink font-medium" aria-label="Workspace" + data-composer-context-control > {effectiveEnvMode === "worktree" ? ( @@ -94,9 +98,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe )} - + + + diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 2cf99547752a..b5d5751a280b 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -41,9 +41,17 @@ 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 ? ( ) : ( @@ -51,9 +59,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir )} - {activeEnvironment?.label ?? "Run on"} + + {activeEnvironment?.label ?? "Run on"} + ); @@ -71,6 +84,7 @@ 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 ? ( @@ -79,9 +93,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir )} - + + + 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 1335e6bb05b2..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,40 @@ 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. */ @@ -1396,6 +1540,7 @@ function ChatMarkdown({ theme={resolvedTheme} threadRef={threadRef} onOpen={openInPreferredEditor} + onOpenInPanel={openFileInPanel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -1412,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 = @@ -1451,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) { @@ -1469,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) => { @@ -1498,7 +1679,7 @@ function ChatMarkdown({ }); }} > - {faviconHost ? ( + {faviconHost && hastHasText(node) ? ( {children} @@ -1529,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); @@ -1588,6 +1772,7 @@ function ChatMarkdown({ isStreaming, markdownFileLinkMetaByHref, onTaskListChange, + openFileInPanel, openInPreferredEditor, openExternalLinkInPreview, openMarkdownFileInPreview, @@ -1598,10 +1783,13 @@ function ChatMarkdown({ ]); /* 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 c260c9e91184..0a5b7bb8c601 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,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, @@ -81,7 +86,7 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, - findSidebarProposedPlan, + deriveTurnPlans, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, @@ -117,17 +122,13 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; -import { - clearPlanSidebarDismissal, - dismissPlanSidebarForTurn, - isPlanSidebarDismissedForTurn, -} from "../planSidebarDismissal"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, type RightPanelSurface, + updatePullRequestTabStatus, useRightPanelStore, } from "../rightPanelStore"; import { @@ -140,11 +141,16 @@ 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, @@ -153,13 +159,13 @@ import { 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, + PaperclipIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; @@ -174,9 +180,15 @@ 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"; @@ -184,9 +196,11 @@ 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, @@ -219,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"; @@ -236,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 { @@ -269,6 +301,8 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + hasEnvironmentReconnectWarningGraceElapsed, + scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, @@ -437,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) { @@ -456,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; } @@ -1230,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 @@ -1243,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. @@ -1288,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([]); @@ -1308,15 +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); - // 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); @@ -1465,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, @@ -1506,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; @@ -1528,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); @@ -1549,10 +1654,10 @@ function ChatViewContent(props: ChatViewProps) { ); 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; @@ -1579,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({ @@ -1625,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); @@ -1639,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()); @@ -1677,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) { @@ -1710,7 +1859,6 @@ function ChatViewContent(props: ChatViewProps) { }, [retryEnvironment], ); - const projectGroupingSettings = selectProjectGroupingSettings(settings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -1744,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) => { @@ -1850,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 ?? @@ -1884,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 @@ -1926,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: ( deriveWorkLogEntries(threadActivities), [threadActivities]); + const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the // Agents surface, live strip, and workflow cards. v2Projection is null // until orchestration-v2 lands (source precedence lives in the derive). @@ -2131,21 +2279,25 @@ function ChatViewContent(props: ChatViewProps) { activeLatestTurn?.turnId ?? null, ); }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); - const sidebarProposedPlan = useMemo( - () => - findSidebarProposedPlan({ - threads: threadPlanCatalog, - latestTurn: activeLatestTurn, - latestTurnSettled, - threadId: activeThread?.id ?? null, - }), - [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], - ); const activePlan = useMemo( () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); - const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; + // Current step for the in-chat working row: only for the running turn's own + // plan (deriveActivePlanState falls back to older turns' plans, which must + // not label fresh work). Falls back to the first pending step so an + // all-pending freshly written plan labels the row, matching the chip and + // the server's planProgress. + const workingStepLabel = useMemo(() => { + if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) { + return null; + } + return ( + activePlan.steps.find((step) => step.status === "inProgress")?.step ?? + activePlan.steps.find((step) => step.status === "pending")?.step ?? + null + ); + }, [activeLatestTurn?.turnId, activePlan]); const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -2414,8 +2566,13 @@ function ChatViewContent(props: ChatViewProps) { }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); const timelineEntries = useMemo( () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], + deriveTimelineEntries( + timelineMessages, + activeThread?.proposedPlans ?? [], + workLogEntries, + turnPlans, + ), + [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries], ); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = @@ -2524,7 +2681,7 @@ function ChatViewContent(props: ChatViewProps) { ) ? activeProviderStatus : null; - const hasTimelineTopBanner = Boolean(threadError) || visibleProviderStatus !== null; + const hasTimelineTopBanner = Boolean(visibleThreadError) || visibleProviderStatus !== null; const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -2532,7 +2689,11 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const showComposerContextStrip = isGitRepo && activeProject !== null; + const showComposerContextStrip = shouldShowComposerContextStrip({ + hasActiveProject: activeProject !== null, + isGitRepo, + showEnvironmentIndicator: showComposerEnvironmentIndicator, + }); const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; @@ -3121,47 +3282,15 @@ function ChatViewContent(props: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); - const dismissPlanSidebarForCurrentTurn = useCallback(() => { - if (!activeThreadKey) return; - dismissPlanSidebarForTurn( - activeThreadKey, - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__", - ); - }, [activeThreadKey, activePlan?.turnId, sidebarProposedPlan?.turnId]); - const togglePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; - if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } else if (activeThreadKey) { - clearPlanSidebarDismissal(activeThreadKey); - } - useRightPanelStore.getState().toggle(activeThreadRef, "plan"); - }, [activeThreadKey, activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); - const closePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; - setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); - dismissPlanSidebarForCurrentTurn(); - }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); const createBrowserSurface = useCallback(() => { if (!activeThreadRef) return; void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); }, [activeThreadRef, openPreview]); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; - if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - }, [ - activeThreadRef, - dismissPlanSidebarForCurrentTurn, - isGitRepo, - isServerThread, - onDiffPanelOpen, - planSidebarOpen, - ]); + }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -3177,6 +3306,27 @@ function ChatViewContent(props: ChatViewProps) { }, [activeProject, activeThreadRef], ); + // The thread's own change request, placed against the project it belongs to. Without a + // project there is nothing to resolve it against, so the caller falls back to the browser. + const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const openThreadPullRequest = useCallback( + (number: number) => { + if ( + !supportsPullRequests || + !activeThreadRef || + !activeProject || + threadRepository === null + ) { + return; + } + useRightPanelStore.getState().openPullRequest(activeThreadRef, { + projectId: activeProject.id, + repository: threadRepository, + number, + }); + }, + [activeProject, activeThreadRef, supportsPullRequests, threadRepository], + ); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { @@ -3297,11 +3447,6 @@ function ChatViewContent(props: ChatViewProps) { const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - if (surface.kind === "plan") { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - } else if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId); @@ -3313,20 +3458,16 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen?.(); } }, - [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], + [activeThreadRef, diffOpen, onDiffPanelOpen], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; if (rightPanelOpen) { - if (planSidebarOpen) { - closePlanSidebar(); - } else { - closePreviewPanel(); - } + closePreviewPanel(); return; } useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); + }, [activeThreadRef, closePreviewPanel, rightPanelOpen]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; setMaximizedRightPanelThreadKey((threadKey) => @@ -3336,10 +3477,6 @@ function ChatViewContent(props: ChatViewProps) { const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; - if (surfaces.some((surface) => surface.kind === "plan")) { - dismissPlanSidebarForCurrentTurn(); - } - for (const surface of surfaces) { if (surface.kind === "preview" && surface.resourceId) { void closePreviewSession({ @@ -3365,7 +3502,6 @@ function ChatViewContent(props: ChatViewProps) { activePreviewState.sessions, closePreview, closeTerminalMutation, - dismissPlanSidebarForCurrentTurn, storeCloseTerminal, ], ); @@ -3547,31 +3683,27 @@ function ChatViewContent(props: ChatViewProps) { new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const timelineScrollModeRef = useRef("following-end"); + // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd + // re-pins on its own (independent of the refs), so the timeline needs a + // render-visible flag to switch it off once the user scrolls away. + const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); const activeTimelineAnchorIndexRef = useRef(null); const anchorUserScrollGenerationRef = useRef(0); const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); + // Manual navigation stops live-follow without removing anchored end space. + // Collapsing that space during a gesture clamps the viewport back to the end. const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; + setTimelineLiveFollowEnabled(false); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } }, []); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, @@ -3627,52 +3759,140 @@ function ChatViewContent(props: ChatViewProps) { }, [composerOverlayHeight], ); - // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. const scrollToEnd = useCallback((animated = false) => { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - void legendListRef.current?.scrollToEnd?.({ animated }); + setTimelineAnchor((current) => + current.messageId === null ? current : { ...current, messageId: null }, + ); + requestAnimationFrame(() => { + void legendListRef.current?.scrollToEnd?.({ animated }); + }); }, []); useEffect(() => { let removeListeners: (() => void) | null = null; - const frame = requestAnimationFrame(() => { - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - scrollNode.addEventListener("wheel", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handleManualNavigation, { - passive: true, + let frame: number | null = null; + const attach = (remainingAttempts: number) => { + frame = requestAnimationFrame(() => { + frame = null; + const scrollNode = legendListRef.current?.getScrollableNode(); + if (!scrollNode) { + // The list may not have mounted on the first frame after a thread + // switch — without a retry the opt-out listeners never attach and + // live-follow becomes impossible to escape for the whole thread. + if (remainingAttempts > 0) { + attach(remainingAttempts - 1); + } + return; + } + const handleManualNavigation = () => { + cancelTimelineLiveFollowForUserNavigationRef.current(); + }; + // The gestures below must only break follow when they can actually + // move the viewport away from the live edge. Follow now gates + // LegendList's maintainScrollAtEnd, so a spurious break while pinned + // at the end produces no scroll event, never re-arms, and streaming + // silently stops following. Underflowing content can't scroll at all, + // so nothing there should break follow. + const contentScrollsUp = () => timelineRealContentOverflowsViewport(); + // The follow re-arm band, not the strict flag: streaming growth makes + // isAtEnd flicker false for a frame before the follow scroll catches + // up, and a gesture landing in that window while still pinned would + // otherwise break follow with no scroll event left to re-arm it. + const viewportIsAwayFromEnd = () => + resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) === + false; + // Only an upward wheel is a navigation intent; wheeling down while + // following either does nothing (at the end) or moves toward it. + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0 && contentScrollsUp()) { + handleManualNavigation(); + } + }; + // Touch direction isn't observable here (touchmove fires on any + // finger motion, scrolling or not), so break only once the drag has + // actually carried the viewport out of the end band — an upward flick + // gets there within its first few events and later touchmoves break. + const handleTouchMove = () => { + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Scrollbar drags produce no wheel/touch events; they are the only + // pointerdowns whose target is the scroll node itself rather than a + // message row. Content clicks break follow only away from the end + // (reading or selecting up there must hold position); clicking near + // the live edge keeps following. + const handlePointerDown = (event: PointerEvent) => { + if (event.target === scrollNode) { + if (contentScrollsUp()) { + handleManualNavigation(); + } + return; + } + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and + // pointer events entirely; without this the timeline yanks back to + // the end on the next stream chunk. + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "PageUp": + case "Home": + case "ArrowUp": + if (contentScrollsUp()) { + handleManualNavigation(); + } + break; + default: + break; + } + }; + scrollNode.addEventListener("wheel", handleWheel, { + passive: true, + }); + scrollNode.addEventListener("touchmove", handleTouchMove, { + passive: true, + }); + scrollNode.addEventListener("pointerdown", handlePointerDown, { + passive: true, + }); + scrollNode.addEventListener("keydown", handleKeyDown); + removeListeners = () => { + scrollNode.removeEventListener("wheel", handleWheel); + scrollNode.removeEventListener("touchmove", handleTouchMove); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + scrollNode.removeEventListener("keydown", handleKeyDown); + }; }); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleManualNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handleManualNavigation); - }; - }); + }; + attach(12); return () => { - cancelAnimationFrame(frame); + if (frame !== null) { + cancelAnimationFrame(frame); + } removeListeners?.(); }; - }, [activeThread?.id]); + }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { + // Anchored-end space can be remeasured when the turn completes. Once the + // user has scrolled away (or returned to ordinary end-following), that + // remeasurement must not restart the send-time anchor positioning. + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } if (pendingTimelineAnchorRef.current === messageId) { pendingTimelineAnchorRef.current = null; } @@ -3694,75 +3914,23 @@ function ChatViewContent(props: ChatViewProps) { } return; } - const scrollNode = list.getScrollableNode(); - let finished = false; - const finishAnimatedPositioning = () => { - if (finished) { - return; - } - finished = true; - window.clearTimeout(fallbackTimer); - scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const scrollOffset = list.getState().scroll; - void list.scrollToOffset({ offset: scrollOffset, animated: false }); - settledTimelineAnchorRef.current = messageId; - }; - const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750); - scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); - void list.scrollToIndex({ - index: anchorIndex, - animated: true, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }); + void list + .scrollToIndex({ + index: anchorIndex, + animated: true, + viewPosition: 0, + viewOffset: CHAT_LIST_ANCHOR_OFFSET, + }) + .then(() => { + if (positionedTimelineAnchorRef.current !== messageId) { + return; + } + settledTimelineAnchorRef.current = messageId; + }); }); }; requestAnimationFrame(() => positionAnchor(12)); }, []); - const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = legendListRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== null) { - return; - } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { - const list = legendListRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }, []); const onIsAtEndChange = useCallback((isAtEnd: boolean) => { if ( @@ -3778,6 +3946,7 @@ function ChatViewContent(props: ChatViewProps) { if (isAtEnd) { timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { @@ -3787,6 +3956,9 @@ function ChatViewContent(props: ChatViewProps) { } }, []); + // Anchored end space intentionally disables LegendList's normal end-follow so + // the sent message can stay near the top. T3 only owns streaming adjustments + // during that mode; LegendList owns ordinary end-follow everywhere else. useEffect(() => { if (!activeThread?.id) { return; @@ -3794,6 +3966,9 @@ function ChatViewContent(props: ChatViewProps) { if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { return; } + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } let secondFrame: number | null = null; const frame = requestAnimationFrame(() => { @@ -3815,28 +3990,13 @@ function ChatViewContent(props: ChatViewProps) { return; } - if (timelineScrollModeRef.current === "anchoring-new-turn") { - const metrics = getActiveTimelineTurnMetrics(list); - if (!metrics) { - return; - } - if (metrics.scrollDeltaToRevealEnd <= 1) { - return; - } - - const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; - void list.scrollToOffset({ offset: nextOffset, animated: false }); - return; - } - - if (timelineScrollModeRef.current !== "following-end") { - return; - } - if (!timelineRealContentOverflowsViewport(list)) { + const metrics = getActiveTimelineTurnMetrics(list); + if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) { return; } - void list.scrollToEnd?.({ animated: false }); + const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; + void list.scrollToOffset({ offset: nextOffset, animated: false }); }); }); @@ -3846,55 +4006,23 @@ function ChatViewContent(props: ChatViewProps) { cancelAnimationFrame(secondFrame); } }; - }, [ - activeThread?.id, - timelineEntries, - getActiveTimelineTurnMetrics, - timelineRealContentOverflowsViewport, - ]); + }, [activeThread?.id, timelineEntries, getActiveTimelineTurnMetrics]); useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - if (planSidebarOpenOnNextThreadRef.current) { - planSidebarOpenOnNextThreadRef.current = false; - if (activeThreadRef) { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - useRightPanelStore.getState().open(activeThreadRef, "plan"); - } - } // activeThreadRef resets transitively with the active thread. }, [activeThread?.id]); - // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. - // Don't auto-open for plans carried over from a previous turn (the user can open manually). - useEffect(() => { - if (!autoOpenPlanSidebar) return; - if (!activePlan) return; - if (planSidebarOpen) return; - const latestTurnId = activeLatestTurn?.turnId ?? null; - if (latestTurnId && activePlan.turnId !== latestTurnId) return; - const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - if (!activeThreadRef) return; - if (isPlanSidebarDismissedForTurn(scopedThreadKey(activeThreadRef), turnKey)) return; - useRightPanelStore.getState().open(activeThreadRef, "plan"); - }, [ - activePlan, - activeLatestTurn?.turnId, - activeThreadRef, - autoOpenPlanSidebar, - planSidebarOpen, - sidebarProposedPlan?.turnId, - ]); - useEffect(() => { setIsRevertingCheckpoint(false); }, [activeThread?.id]); @@ -3999,20 +4127,36 @@ function ChatViewContent(props: ChatViewProps) { // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); - const activeThreadPr = resolveThreadPr({ + const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); + const activeThreadPr = resolveDisplayedThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, + snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, + retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, }); + // The right panel offers the thread's own change request, so it can only offer it once the + // branch has one; until then the picker says so rather than opening an empty panel. + const addPullRequestSurface = useCallback(() => { + if (activeThreadPr === null) return; + openThreadPullRequest(activeThreadPr.number); + }, [activeThreadPr, openThreadPullRequest]); + const pullRequestSurfaceAvailable = + supportsPullRequests && activeThreadPr !== null && threadRepository !== null; const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const nowMinute = useNowMinute(); + const snoozeNow = new Date().toISOString(); const activeThreadSnoozed = activeThreadShell !== null && supportsSnooze && - effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() }); + effectiveSnoozed(activeThreadShell, { now: snoozeNow }); const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + void snoozeWakeTick; + const activeThreadWokeAt = + activeThreadShell !== null && supportsSnooze + ? threadWokeAt(activeThreadShell, { now: snoozeNow }) + : null; useEffect(() => { - void snoozeWakeTick; if (!activeThreadSnoozed) return; const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); if (!Number.isFinite(wakeAtMs)) return; @@ -4022,17 +4166,55 @@ function ChatViewContent(props: ChatViewProps) { ); return () => window.clearTimeout(id); }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); + const acknowledgeActiveThreadWoke = useCallback(() => { + if (activeThreadRef === null || activeThreadWokeAt === null) return; + markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); + }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); + // Mirror of the sidebar's Woke pill for the open thread. It uses the same + // visit comparison and change request settle rule. + const activeThreadLastVisitedAt = useUiStateStore((store) => + activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], + ); + const activeThreadWokeVisible = useMemo(() => { + if (activeThreadWokeAt === null) return false; + if (changeRequestAutoSettles(activeThreadPr?.state, autoSettleOnMerge)) return false; + const wokeAtMs = Date.parse(activeThreadWokeAt); + if (Number.isNaN(wokeAtMs)) return false; + // Having the thread open counts as a visit at completedAt (the effect + // above stamps it); folding that floor in here keeps a completion- + // triggered wake from flashing a banner for one frame before the stamp + // lands. An unparseable stored visit counts as never-visited: corrupt + // local data must not eat the wake signal. + const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; + const completedAtMs = activeLatestTurn?.completedAt + ? Date.parse(activeLatestTurn.completedAt) + : NaN; + const lastVisitedMs = Math.max( + Number.isNaN(storedVisitMs) ? -Infinity : storedVisitMs, + Number.isNaN(completedAtMs) ? -Infinity : completedAtMs, + ); + return lastVisitedMs < wokeAtMs; + }, [ + activeLatestTurn?.completedAt, + activeThreadLastVisitedAt, + activeThreadPr?.state, + activeThreadWokeAt, + autoSettleOnMerge, + ]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { now: `${nowMinute}:00.000Z`, autoSettleAfterDays, + autoSettleOnMerge, changeRequestState: activeThreadPr?.state ?? null, }); }, [ activeThreadPr?.state, activeThreadShell, autoSettleAfterDays, + autoSettleOnMerge, + changeRequestSnapshotByKey, nowMinute, supportsSettlement, ]); @@ -4281,9 +4463,30 @@ function ChatViewContent(props: ChatViewProps) { handleStopBackgroundWork, isStoppingBackgroundWork, ]); + // A woken thread announces itself in the open view, not just the sidebar + // pill. Dismissing marks the wake as seen (same acknowledgment as the + // pill); sending a message clears it as a side effect of the send path. + const wokeThreadBannerItem = useMemo(() => { + if (!activeThreadWokeVisible) { + return null; + } + return { + id: `thread-woke:${activeThread?.id ?? "unknown"}`, + variant: "info", + icon: , + title: "This thread woke from snooze", + description: "Dismiss to clear the Woke indicator, or send a message to keep going.", + dismissLabel: "Dismiss Woke notification", + onDismiss: acknowledgeActiveThreadWoke, + }; + }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); // The stack renders items[0] front-most and tucks the rest behind hover, so - // ordering is priority: system banners, then the branch-mismatch notice, - // and the informational parked-thread banner last — it must never cover another. + // ordering is priority: urgent system banners (error/warning variants plus + // calm-styled live states flagged `urgent`, like update progress), then + // background liveness — its Stop button is the only stop affordance for + // settled turns, so a passive "update available" notice must not cover it — + // then calm system banners, the woke and branch-mismatch notices, and the + // informational parked-thread banner last — it must never cover another. const parkedThreadBannerItem = useMemo(() => { if (!activeThreadSnoozed && !activeThreadSettled) { return null; @@ -4333,15 +4536,28 @@ function ChatViewContent(props: ChatViewProps) { void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { + const isUrgentSystemItem = (item: ComposerBannerStackItem) => + item.urgent === true || item.variant === "error" || item.variant === "warning"; + const urgentSystemItems = systemComposerBannerItems.filter(isUrgentSystemItem); + const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; + const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { - return [...systemComposerBannerItems, ...backgroundLivenessItems, ...parkedThreadItems]; + return [ + ...urgentSystemItems, + ...backgroundLivenessItems, + ...calmSystemItems, + ...wokeThreadItems, + ...parkedThreadItems, + ]; } return [ - ...systemComposerBannerItems, + ...urgentSystemItems, ...backgroundLivenessItems, + ...calmSystemItems, + ...wokeThreadItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, variant: "info", @@ -4392,6 +4608,7 @@ function ChatViewContent(props: ChatViewProps) { parkedThreadBannerItem, showBranchMismatchBanner, systemComposerBannerItems, + wokeThreadBannerItem, ]); useEffect(() => { @@ -4522,6 +4739,13 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (command === "rightPanel.toggleMaximized") { + event.preventDefault(); + event.stopPropagation(); + toggleRightPanelMaximized(); + return; + } + if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); @@ -4617,6 +4841,7 @@ function ChatViewContent(props: ChatViewProps) { keybindings, onToggleDiff, toggleRightPanel, + toggleRightPanelMaximized, toggleTerminalVisibility, composerRef, ]); @@ -4643,6 +4868,7 @@ function ChatViewContent(props: ChatViewProps) { "This will discard newer messages and turn diffs in this thread.", "This action cannot be undone.", ].join("\n"), + { variant: "destructive" }, ); if (!confirmed) { return; @@ -4703,12 +4929,21 @@ function ChatViewContent(props: ChatViewProps) { isSendBusy || isConnecting || threadDetailLoading || - activeEnvironmentUnavailable || sendInFlightRef.current ) { notifyDirectAnnotationAttached(); return; } + if (activeEnvironmentUnavailable) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Not connected: message not sent", + description: "Reconnecting to the environment. Try again once it is connected.", + }), + ); + return; + } if (activePendingProgress) { if (directAnnotation) { notifyDirectAnnotationAttached(); @@ -4774,6 +5009,16 @@ function ChatViewContent(props: ChatViewProps) { draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); + const outgoingFollowUpText = formatOutgoingPrompt({ + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, + text: followUp.text.trim(), + }); + if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { + return; + } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -4783,7 +5028,10 @@ function ChatViewContent(props: ChatViewProps) { }); return; } + // Legacy plan mode: /plan and /default only act when the beta flag is on; + // otherwise they send as plain text like any other message. const standaloneSlashCommand = + settings.planModeEnabled && composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && @@ -4840,24 +5088,6 @@ function ChatViewContent(props: ChatViewProps) { return; } - sendInFlightRef.current = true; - if (isDraftHeroState && activeThreadKey) { - let resolveDockStarted: (() => void) | undefined; - const dockStarted = new Promise((resolve) => { - resolveDockStarted = resolve; - }); - const dockTransition = runMobileComposerTransition(() => { - flushSync(() => { - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); - }); - resolveDockStarted?.(); - }); - void dockTransition.catch(() => resolveDockStarted?.()); - await dockStarted; - } - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); - const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; @@ -4875,8 +5105,6 @@ function ChatViewContent(props: ChatViewProps) { messageTextWithPreviewAnnotations, composerReviewCommentsSnapshot, ); - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -4884,6 +5112,30 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); + if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { + return; + } + + sendInFlightRef.current = true; + if (isDraftHeroState && activeThreadKey) { + let resolveDockStarted: (() => void) | undefined; + const dockStarted = new Promise((resolve) => { + resolveDockStarted = resolve; + }); + const dockTransition = runMobileComposerTransition(() => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }); + void dockTransition.catch(() => resolveDockStarted?.()); + await dockStarted; + } + beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); + + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -4907,6 +5159,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -5063,6 +5316,7 @@ function ChatViewContent(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + acknowledgeActiveThreadWoke(); } } @@ -5350,6 +5604,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -5422,15 +5677,7 @@ function ChatViewContent(props: ChatViewProps) { } if (failure === null) { - // Optimistically open the plan sidebar when implementing (not refining). - // "default" mode here means the agent is executing the plan, which produces - // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default" && autoOpenPlanSidebar) { - if (activeThreadRef) { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - useRightPanelStore.getState().open(activeThreadRef, "plan"); - } - } + acknowledgeActiveThreadWoke(); sendInFlightRef.current = false; return; } @@ -5451,6 +5698,7 @@ function ChatViewContent(props: ChatViewProps) { [ activeThread, activeProposedPlan, + acknowledgeActiveThreadWoke, beginLocalDispatch, isConnecting, isSendBusy, @@ -5462,7 +5710,6 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftInteractionMode, setThreadError, startThreadTurn, - autoOpenPlanSidebar, environmentId, composerRef, ], @@ -5505,6 +5752,9 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: implementationPrompt, }); + if (composerRef.current?.validateProviderInput(outgoingImplementationPrompt) === false) { + return; + } const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; @@ -5565,8 +5815,6 @@ function ChatViewContent(props: ChatViewProps) { } if (failure === null) { - // Signal that the plan sidebar should open on the new thread when enabled. - planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; const navigateResult = await settlePromise(() => navigate({ to: "/$environmentId/$threadId", @@ -5623,7 +5871,6 @@ function ChatViewContent(props: ChatViewProps) { resetLocalDispatch, runtimeMode, startThreadTurn, - autoOpenPlanSidebar, environmentId, composerRef, ]); @@ -5807,6 +6054,11 @@ function ChatViewContent(props: ChatViewProps) { rightPanelAvailable={activeProject !== null} rightPanelOpen={rightPanelOpen} rightPanelShortcutLabel={shortcutLabelForCommand(keybindings, "rightPanel.toggle")} + // Suppressed while the Agents surface is visible: the roster itself is + // on screen, so the toggle badge would be pointing at nothing. + liveAgentCount={ + rightPanelOpen && activeRightPanelSurface?.kind === "agents" ? 0 : agentPanelModel.liveCount + } onToggleTerminal={toggleTerminalVisibility} onToggleRightPanel={toggleRightPanel} /> @@ -5814,13 +6066,14 @@ function ChatViewContent(props: ChatViewProps) { const panelLayoutControls = (
    - {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( + {rightPanelOpen && !shouldUseRightPanelSheet ? ( - ) : activeRightPanelSurface?.kind === "plan" ? ( - + ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( + + ) : activeRightPanelSurface?.kind === "pull-request" ? ( + // No onClose: the surface tab's own X owns closing here, and a second X in the header + // would be the same action twice. The thread context also drops the checkout button, so it + // is only right for the thread's own pull request, whose branch is already under the + // reader's feet. A link the agent wrote can open any other one here, and that one has to be + // checkable out like it is anywhere else. + ) : activeRightPanelSurface?.kind === "agents" ? ( composerRef.current?.addDroppedFiles(files), + }); + return (
    - {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} + {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
    {!rightPanelOpen ? panelLayoutControls : null} setThreadError(activeThread.id, null)} + error={visibleThreadError} + onDismiss={() => { + setThreadError(activeThread.id, null); + dismissThreadErrorBannerForSession(threadErrorBannerKey); + setThreadErrorBannerDismissTick((tick) => tick + 1); + }} /> {/* Main content area with optional plan sidebar */}
    {/* Chat column */} -
    +
    + {isWorkspaceFileDragActive ? ( +
    +
    +
    +
    + ) : null} {/* Provider status overlays the timeline without changing its content height. */}
    {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -6026,16 +6346,16 @@ function ChatViewContent(props: ChatViewProps) { className="pointer-events-none absolute left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5" style={{ bottom: composerOverlayHeight + 4 }} > - +
    )}
    @@ -6052,7 +6372,7 @@ function ChatViewContent(props: ChatViewProps) { >
    {isDraftHeroState ? ( @@ -6127,10 +6447,6 @@ function ChatViewContent(props: ChatViewProps) { respondingRequestIds={respondingRequestIds} showPlanFollowUpPrompt={showPlanFollowUpPrompt} activeProposedPlan={activeProposedPlan} - activePlan={activePlan as { turnId?: TurnId } | null} - sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} - planSidebarLabel={planSidebarLabel} - planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} interactionMode={interactionMode} lockedProvider={lockedProvider} @@ -6168,7 +6484,6 @@ function ChatViewContent(props: ChatViewProps) { toggleInteractionMode={toggleInteractionMode} handleRuntimeModeChange={handleRuntimeModeChange} handleInteractionModeChange={handleInteractionModeChange} - togglePlanSidebar={togglePlanSidebar} focusComposer={focusComposer} scheduleComposerFocus={scheduleComposerFocus} setThreadError={setThreadError} @@ -6186,6 +6501,7 @@ function ChatViewContent(props: ChatViewProps) { - {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( + {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( {rightPanelContent} ) : null} - {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( - + {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( + {panelToggleControls}
    } surfaces={rightPanelState.surfaces} activeSurfaceId={activeRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} + desktopByTabId={activePreviewState.desktopByTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} @@ -6349,10 +6677,16 @@ function ChatViewContent(props: ChatViewProps) { onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} + onAddPullRequest={addPullRequestSurface} onAddAgents={addAgentsSurface} browserAvailable={isPreviewSupportedInRuntime()} + terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + pullRequestAvailable={pullRequestSurfaceAvailable} + agentsAvailable + pullRequestStatuses={pullRequestTabStatuses} + liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 4d591500f5c6..9bae9c58a977 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,14 +2,45 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + browseInputEndPaddingClass, buildBrowseGroups, buildThreadActionItems, enumerateCommandPaletteItems, + filterPinnedBrowseEntries, filterCommandPaletteGroups, reduceCommandPaletteUiState, type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("browseInputEndPaddingClass", () => { + it("reserves the widest space for the create action", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: true, + hasHighlightedBrowseItem: false, + }), + ).toContain("pe-38"); + }); + + it("reserves space for the wider highlighted-item shortcut", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: false, + hasHighlightedBrowseItem: true, + }), + ).toContain("pe-30"); + }); + + it("keeps the compact reserve for the normal add action", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: false, + hasHighlightedBrowseItem: false, + }), + ).toContain("pe-24"); + }); +}); + describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; @@ -264,6 +295,20 @@ describe("buildThreadActionItems", () => { expect(item?.description).toBe("T3 Code · #feat/search"); }); + it("prefers renderDescription when provided", () => { + const [item] = buildThreadActionItems({ + threads: [makeThread({ branch: "feat/search", worktreePath: "/tmp/wt" })], + projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), + sortOrder: "updated_at", + icon: null, + renderDescription: (thread, { projectTitle }) => + `${projectTitle}:${thread.branch}:${thread.worktreePath ? "wt" : "local"}`, + runThread: async (_thread) => undefined, + }); + + expect(item?.description).toBe("T3 Code:feat/search:wt"); + }); + it("filters archived threads out of thread search items", () => { const items = buildThreadActionItems({ threads: [ @@ -327,3 +372,39 @@ describe("buildBrowseGroups", () => { expect(actionSettled).toBe(true); }); }); + +describe("filterPinnedBrowseEntries", () => { + const entries = [ + { name: "repo", fullPath: "/projects/repo" }, + { name: "work", fullPath: "/projects/work" }, + ]; + + it("shows sibling folders without losing an existing pinned destination", () => { + expect( + filterPinnedBrowseEntries({ + browseEntries: entries, + filterQuery: "repo", + pinnedDirectoryName: "repo", + caseSensitive: true, + }), + ).toEqual({ visibleEntries: entries, exactEntry: entries[0] }); + }); + + it("matches an existing pinned destination without Windows casing", () => { + const windowsEntries = [ + { name: "Repo", fullPath: "C:\\projects\\Repo" }, + { name: "work", fullPath: "C:\\projects\\work" }, + ]; + expect( + filterPinnedBrowseEntries({ + browseEntries: windowsEntries, + filterQuery: "repo", + pinnedDirectoryName: "repo", + caseSensitive: false, + }), + ).toEqual({ + visibleEntries: windowsEntries, + exactEntry: windowsEntries[0], + }); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index eee6ba5886e6..ed758830f4a1 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -3,6 +3,7 @@ import { type KeybindingCommand, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; +import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; @@ -12,9 +13,22 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; export const RECENT_THREAD_LIMIT = 12; -export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; +export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; +export function browseInputEndPaddingClass(input: { + readonly willCreateProjectPath: boolean; + readonly hasHighlightedBrowseItem: boolean; +}): string { + if (input.willCreateProjectPath) { + return "*:data-[slot=autocomplete-input]:pe-38!"; + } + if (input.hasHighlightedBrowseItem) { + return "*:data-[slot=autocomplete-input]:pe-30!"; + } + return "*:data-[slot=autocomplete-input]:pe-24!"; +} + /** * The global search overlay hosts three mutually exclusive surfaces: the * command palette (⌘K), the project file picker (⌘P), and project content @@ -154,7 +168,16 @@ export function buildProjectActionItems(input: { export type BuildThreadActionItemsThread = Pick< SidebarThreadSummary, - "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" + | "archivedAt" + | "branch" + | "createdAt" + | "environmentId" + | "id" + | "modelSelection" + | "projectId" + | "session" + | "title" + | "worktreePath" > & { updatedAt: string; latestUserMessageAt?: string | null; @@ -170,6 +193,8 @@ export function buildThreadActionItems ReactNode; /** Optional content rendered inline after the title text per-thread. */ renderTrailingContent?: (thread: TThread) => ReactNode; + /** Optional rich description (e.g. favicon + workspace icons). Falls back to text. */ + renderDescription?: (thread: TThread, meta: { projectTitle: string | undefined }) => ReactNode; getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined; runThread: (thread: Pick) => Promise; limit?: number; @@ -198,6 +223,9 @@ export function buildThreadActionItems; + filterQuery: string; + pinnedDirectoryName: string; + caseSensitive: boolean; +}): ReturnType { + const namesMatch = (left: string, right: string) => + input.caseSensitive ? left === right : left.toLowerCase() === right.toLowerCase(); + const visibleFilterQuery = namesMatch(input.filterQuery, input.pinnedDirectoryName) + ? "" + : input.filterQuery; + const { visibleEntries } = filterFilesystemBrowseEntries(input.browseEntries, visibleFilterQuery); + const exactEntry = + input.filterQuery.length > 0 + ? (input.browseEntries.find((entry) => namesMatch(entry.name, input.filterQuery)) ?? null) + : null; + return { visibleEntries, exactEntry }; +} + export function getCommandPaletteMode(input: { currentView: CommandPaletteView | null; isBrowsing: boolean; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 60493063664c..410be73b420a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,7 +1,12 @@ "use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { + canCreateProjectInEnvironment, + getCloneDestinationBrowsePath, + getCloneDestinationPath, + getCloneDirectoryName, +} from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { @@ -35,6 +40,7 @@ import { FolderPlusIcon, LinkIcon, MessageSquareIcon, + PaletteIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -57,6 +63,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useClientSettings } from "../hooks/useSettings"; +import { useTheme } from "../hooks/useTheme"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; import { filesystemEnvironment } from "../state/filesystem"; @@ -96,6 +103,7 @@ import { } from "../wslPaths"; import { ADDON_ICON_CLASS, + browseInputEndPaddingClass, buildBrowseGroups, buildProjectActionItems, buildRootGroups, @@ -106,6 +114,7 @@ import { type CommandPaletteSubmenuItem, type CommandPaletteView, filterCommandPaletteGroups, + filterPinnedBrowseEntries, getCommandPaletteInputPlaceholder, getCommandPaletteMode, ITEM_ICON_CLASS, @@ -121,9 +130,15 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons" import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; +import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; +import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; -import { resolveDefaultProviderModelSelection } from "../providerInstances"; +import { + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, + type ProviderInstanceEntry, +} from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { CommandDialog, CommandDialogPopup } from "./ui/command"; import { Button } from "./ui/button"; @@ -147,6 +162,7 @@ function projectFavicon(project: Project) { ); @@ -386,6 +402,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme } = useTheme(); const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, @@ -428,6 +445,16 @@ export function CommandPalette({ children }: { children: ReactNode }) { previewOpen, }, }); + if (command === "themeEditor.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + return; + } const mode = overlayModeForCommand(command); if (mode === null) { return; @@ -438,7 +465,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, previewOpen, terminalOpen, toggleMode]); + }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]); useEffect( () => @@ -567,7 +594,20 @@ function OpenCommandPaletteDialog(props: { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme } = useTheme(); const providers = useAtomValue(primaryServerProvidersAtom); + const providerEntryByEnvironmentAndInstanceId = useMemo(() => { + const map = new Map(); + for (const environment of environments) { + const environmentProviders = + environment.serverConfig?.providers ?? + (environment.environmentId === primaryEnvironmentId ? providers : []); + for (const entry of deriveProviderInstanceEntries(environmentProviders)) { + map.set(`${environment.environmentId}:${entry.instanceId}`, entry); + } + } + return map; + }, [environments, primaryEnvironmentId, providers]); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const environmentIds = useMemo( @@ -773,6 +813,16 @@ function OpenCommandPaletteDialog(props: { ); const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; + // The destination step pins the repository folder onto the browsed path, so + // the proposed clone target is "/" instead of the bare + // folder. A lookup reports "owner/repo"; a pasted clone URL falls back to its + // own last segment, minus ".git". + const pinnedCloneDirectoryName = + addProjectCloneFlow?.step === "confirm" + ? getCloneDirectoryName( + addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.remoteUrl, + ) + : ""; const browsePath = useMemo( () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep), [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], @@ -800,6 +850,10 @@ function OpenCommandPaletteDialog(props: { new Map(projects.map((project) => [project.id, project.workspaceRoot])), [projects], ); + const projectFaviconPathById = useMemo( + () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)), + [projects], + ); const projectTitleById = useMemo( () => new Map(projects.map((project) => [project.id, project.title])), [projects], @@ -841,8 +895,16 @@ function OpenCommandPaletteDialog(props: { const isBrowsePending = browseQuery.isPending; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( - () => filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery), - [browseEntries, browsePath.filterQuery], + () => + pinnedCloneDirectoryName + ? filterPinnedBrowseEntries({ + browseEntries, + filterQuery: browsePath.filterQuery, + pinnedDirectoryName: pinnedCloneDirectoryName, + caseSensitive: !isWindowsPlatform(browseEnvironmentPlatform), + }) + : filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery), + [browseEntries, browseEnvironmentPlatform, browsePath.filterQuery, pinnedCloneDirectoryName], ); const prefetchBrowsePath = useCallback( @@ -984,6 +1046,29 @@ function OpenCommandPaletteDialog(props: { icon: , renderLeadingContent: (thread) => , renderTrailingContent: (thread) => , + renderDescription: (thread, { projectTitle }) => { + const modelInstanceId = + thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = + providerEntryByEnvironmentAndInstanceId.get( + `${thread.environmentId}:${modelInstanceId}`, + ) ?? null; + return ( + + ); + }, getContentMatch: (thread) => { const match = threadContentMatchByKey.get( threadSearchMatchKey({ @@ -1010,7 +1095,10 @@ function OpenCommandPaletteDialog(props: { activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, + projectCwdById, + projectFaviconPathById, projectTitleById, + providerEntryByEnvironmentAndInstanceId, threadContentMatchByKey, threadSearchQuery, threads, @@ -1463,6 +1551,22 @@ function OpenCommandPaletteDialog(props: { }); } + actionItems.push({ + kind: "action", + value: "action:theme-editor", + searchTerms: ["theme", "appearance", "colors", "palette", "customize"], + title: "Toggle theme editor", + icon: , + shortcutCommand: "themeEditor.toggle", + run: async () => { + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", @@ -1474,6 +1578,33 @@ function OpenCommandPaletteDialog(props: { }, }); + // There is no projects listing page; the action targets the contextual + // project (active thread/draft, falling back to the first sidebar group). + const contextualProjectGroup = + (contextualProjectRef + ? projectGroupByTargetKey.get( + `${contextualProjectRef.environmentId}:${contextualProjectRef.projectId}`, + ) + : null) ?? + projectGroups[0] ?? + null; + if (contextualProjectGroup) { + actionItems.push({ + kind: "action", + value: "action:project-settings", + searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + title: "Project settings", + description: contextualProjectGroup.displayName, + icon: , + run: async () => { + await navigate({ + to: "/projects/$projectKey", + params: { projectKey: contextualProjectGroup.projectKey }, + }); + }, + }); + } + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; @@ -1686,7 +1817,10 @@ function OpenCommandPaletteDialog(props: { const provider = remoteProjectSourceProvider(addProjectCloneFlow.source); if (!provider) { - const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); + const destinationPath = getCloneDestinationPath( + getDefaultCloneParentPath(addProjectCloneFlow.environmentId), + getCloneDirectoryName(rawRepository), + ); setAddProjectCloneFlow({ step: "confirm", environmentId: addProjectCloneFlow.environmentId, @@ -1723,7 +1857,10 @@ function OpenCommandPaletteDialog(props: { return; } const repository = lookupResult.value; - const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); + const destinationPath = getCloneDestinationPath( + getDefaultCloneParentPath(addProjectCloneFlow.environmentId), + getCloneDirectoryName(repository.nameWithOwner), + ); setAddProjectCloneFlow({ step: "confirm", environmentId: addProjectCloneFlow.environmentId, @@ -1799,7 +1936,14 @@ function OpenCommandPaletteDialog(props: { const browseTo = useCallback( async (name: string): Promise => { - const nextQuery = appendBrowsePathSegment(query, name); + const nextQuery = pinnedCloneDirectoryName + ? getCloneDestinationBrowsePath({ + browseDirectoryPath: browsePath.directoryPath, + selectedDirectoryName: name, + cloneDirectoryName: pinnedCloneDirectoryName, + caseSensitive: !isWindowsPlatform(browseEnvironmentPlatform), + }) + : appendBrowsePathSegment(query, name); await browseNavigation.run( () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)), () => { @@ -1809,7 +1953,14 @@ function OpenCommandPaletteDialog(props: { }, ); }, - [browseNavigation, prefetchBrowsePath, query], + [ + browseNavigation, + browseEnvironmentPlatform, + browsePath.directoryPath, + pinnedCloneDirectoryName, + prefetchBrowsePath, + query, + ], ); const browseUp = useCallback(async (): Promise => { @@ -1818,15 +1969,16 @@ function OpenCommandPaletteDialog(props: { return; } + const nextQuery = getCloneDestinationPath(parentPath, pinnedCloneDirectoryName); await browseNavigation.run( () => prefetchBrowsePath(parentPath), () => { setHighlightedItemValue(null); - setQuery(parentPath); + setQuery(nextQuery); setBrowseGeneration((generation) => generation + 1); }, ); - }, [browseNavigation, browsePath.parentPath, prefetchBrowsePath]); + }, [browseNavigation, browsePath.parentPath, pinnedCloneDirectoryName, prefetchBrowsePath]); // Resolve the add-project path from browse data when available. When the // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the @@ -2239,13 +2391,16 @@ function OpenCommandPaletteDialog(props: { footerTrailing={footerTrailing} inputAccessory={inputAccessory} inputProps={{ + // The submit button is absolutely positioned over the field, so the + // inner input must reserve enough room for the full action label. className: addProjectCloneFlow?.step === "repository" - ? "pe-32" + ? "*:data-[slot=autocomplete-input]:pe-32!" : isBrowsing - ? willCreateProjectPath - ? "pe-36" - : "pe-16" + ? browseInputEndPaddingClass({ + willCreateProjectPath, + hasHighlightedBrowseItem, + }) : undefined, placeholder: inputPlaceholder, wrapperClassName: isSubmenu diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 2ab4ef8f3f81..bbdbc28b0609 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -142,7 +142,7 @@ function DisabledCommandPaletteResultRow(props: { ) : null} {props.item.description ? ( - + {props.item.description} ) : null} @@ -193,7 +193,7 @@ function CommandPaletteResultRow(props: { ) : null} {props.item.description ? ( - + {props.item.description} ) : null} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index f64bdedaa59c..f6dfef2489b4 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -71,9 +71,10 @@ import { import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { + COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, + COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME, SKILL_CHIP_ICON_SVG, } from "./composerInlineChip"; import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; @@ -188,7 +189,7 @@ class ComposerMentionNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -256,7 +257,7 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }} /> - {props.skillLabel} + {props.skillLabel} ); @@ -326,7 +327,7 @@ class ComposerSkillNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -397,7 +398,7 @@ class ComposerTerminalContextNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -1747,13 +1748,12 @@ function ComposerPromptEditorInner({ return ( -
    +
    Appearance - // can drive it; keep everything else here. + // The wrapper owns the appearance preference; keep everything else here. "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", className, )} @@ -1765,7 +1765,7 @@ function ComposerPromptEditorInner({ } placeholder={ terminalContexts.length > 0 ? null : ( -
    +
    {placeholder}
    ) diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx new file mode 100644 index 000000000000..c169a1eff7fc --- /dev/null +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -0,0 +1,96 @@ +import { useEffect, useSyncExternalStore } from "react"; + +import { + completeConfirmDialogClose, + readConfirmDialogState, + registerConfirmDialogHost, + respondToConfirmDialog, + subscribeConfirmDialog, +} from "../confirmDialog"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; +import { Button } from "./ui/button"; + +type ConfirmationCopy = { + readonly title: string; + readonly description: string | null; +}; + +export function resolveConfirmDialogCopy(message: string): ConfirmationCopy { + const normalizedMessage = message.trim(); + const lines = normalizedMessage.split("\n"); + const questionLineIndex = lines.findIndex((line) => line.trim().endsWith("?")); + + if (questionLineIndex >= 0) { + const title = lines[questionLineIndex]!.trim(); + const description = lines + .filter((_, index) => index !== questionLineIndex) + .join("\n") + .trim(); + return { title, description: description || null }; + } + + const questionMarkIndex = normalizedMessage.indexOf("?"); + if (questionMarkIndex >= 0) { + return { + title: normalizedMessage.slice(0, questionMarkIndex + 1).trim(), + description: normalizedMessage.slice(questionMarkIndex + 1).trim() || null, + }; + } + + return { + title: "Confirm action", + description: normalizedMessage || "This action requires your confirmation.", + }; +} + +export function ConfirmDialogHost() { + const state = useSyncExternalStore( + subscribeConfirmDialog, + readConfirmDialogState, + readConfirmDialogState, + ); + + useEffect(() => registerConfirmDialogHost(), []); + + const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message); + const confirmVariant = state.status === "idle" ? "default" : state.variant; + const onCancel = () => respondToConfirmDialog(false); + const onConfirm = () => respondToConfirmDialog(true); + + return ( + { + if (!open) onCancel(); + }} + onOpenChangeComplete={(open) => { + if (!open) completeConfirmDialogClose(); + }} + > + + + {copy.title} + {copy.description ? ( + + {copy.description} + + ) : null} + + + }>Cancel + + + + + ); +} diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index 0c22f1702e5e..6a23a0532873 100644 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -1,6 +1,28 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + import { cn } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +/** Canonical connection-phase → dot color mapping shared by every status dot. */ +export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): string { + switch (phase) { + case "connected": + return "bg-success"; + case "connecting": + case "reconnecting": + return "bg-warning"; + case "error": + return "bg-destructive"; + default: + return "bg-muted-foreground/40"; + } +} + +/** Ping halo for transitional phases; null renders no ping. */ +export function connectionPhasePingClassName(phase: EnvironmentConnectionPhase): string | null { + return phase === "connecting" || phase === "reconnecting" ? "bg-warning/60 duration-2000" : null; +} + type ConnectionStatusDotProps = { tooltipText?: string | null; dotClassName: string; @@ -37,7 +59,6 @@ export function ConnectionStatusDot({ const dot = (
    @@ -1111,7 +845,7 @@ export default function DiffPanel({
    ) : ( <> -
    +
    {isSelectedPatchTruncated && (

    This diff was truncated because it exceeded the preview limit. The changes shown are @@ -1120,7 +854,7 @@ export default function DiffPanel({ )} {selectedPatchError && !renderablePatch && (

    -

    {selectedPatchError}

    +

    {selectedPatchError}

    )} {!renderablePatch ? ( @@ -1148,19 +882,41 @@ export default function DiffPanel({ className="min-h-0 flex-1" onClickCapture={(event) => { const composedPath = event.nativeEvent.composedPath?.() ?? []; + for (const node of composedPath) { + if (!(node instanceof HTMLElement)) continue; + // Header controls keep their own actions. In particular, the chevron must + // not also trigger the row handler or the two toggles cancel each other. + if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { + return; + } + } const title = composedPath.find( (node): node is HTMLElement => node instanceof HTMLElement && node.hasAttribute("data-title"), ); const filePath = title?.textContent?.trim(); - if (filePath) openDiffFile(filePath); + // The filename remains the explicit "open in editor" affordance. + if (filePath) { + openDiffFile(filePath); + return; + } + const header = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), + ); + const headerFilePath = header?.querySelector("[data-title]")?.textContent?.trim(); + if (!headerFilePath) return; + const file = codeViewFiles.find( + (candidate) => candidate.filePath === headerFilePath, + ); + if (file) toggleDiffFileCollapsed(file.fileKey); }} >
    diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index c13af4d9560c..a9b7cf542e02 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -14,7 +14,7 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { mode === "embedded" ? "px-2" : "px-4", shouldUseDragRegion ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]" - : "surface-subheader", + : "flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent", ); } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 20e5c3497907..d448a720ebfc 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -22,6 +22,7 @@ import { flushSync } from "react-dom"; import { CheckIcon, ChevronDownIcon, + CloudDownloadIcon, CloudUploadIcon, ExternalLinkIcon, GitBranchPlusIcon, @@ -50,6 +51,7 @@ import { resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { AnimatedHeight } from "./AnimatedHeight"; +import { StartTruncatedPath } from "./StartTruncatedPath"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -95,6 +97,11 @@ interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId; + /** + * Opens the thread's own change request beside it. Absent when the thread has no project to + * place it against, in which case it still opens in the browser. + */ + onOpenPullRequest?: ((number: number) => void) | undefined; } interface PendingDefaultBranchAction { @@ -352,7 +359,7 @@ function GitQuickActionIcon({ const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return ; if (quickAction.kind === "open_publish") return ; - if (quickAction.kind === "run_pull") return ; + if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; if (quickAction.action === "push" || quickAction.action === "commit_push") { @@ -361,6 +368,7 @@ function GitQuickActionIcon({ return ; } if (quickAction.label === "Commit") return ; + if (quickAction.label === "Push") return ; return ; } @@ -971,6 +979,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef, draftId, + onOpenPullRequest, }: GitActionsControlProps) { const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -1213,6 +1222,13 @@ export default function GitActionsControl({ }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); const openExistingPr = useCallback(async () => { + const openPr = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr : null; + // Beside the thread where it was made, the way the browser opens beside it. Checked before + // the shell, which opening in the app does not need. + if (openPr && onOpenPullRequest) { + onOpenPullRequest(openPr.number); + return; + } const api = readLocalApi(); if (!api) { toastManager.add({ @@ -1222,7 +1238,7 @@ export default function GitActionsControl({ }); return; } - const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; + const prUrl = openPr?.url ?? null; if (!prUrl) { toastManager.add({ type: "error", @@ -1242,7 +1258,7 @@ export default function GitActionsControl({ }), ); }); - }, [gitStatusForActions, threadToastData]); + }, [gitStatusForActions, onOpenPullRequest, threadToastData]); runGitActionWithToast = useEffectEvent( async ({ @@ -1908,14 +1924,13 @@ export default function GitActionsControl({ )} + ) : !isThreadRunning ? ( + appSettingsConfirmThreadArchive ? ( +
    + +
    + ) : ( + + + +
    + } + /> + Archive + + ) + ) : null} + + + {isRemoteThread && !isDesktopLocalThread && ( + + + } + > + + + {threadEnvironmentLabel} + + )} + {jumpLabel ? ( + + + } + > + {jumpLabel} + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + )} + + )} + + +
    +
    + + + ); +}); + +interface SidebarProjectThreadListProps { + projectKey: string; + projectExpanded: boolean; + hasOverflowingThreads: boolean; + hiddenThreadStatus: ThreadStatusPill | null; + orderedProjectThreadKeys: readonly string[]; + renderedThreads: readonly SidebarThreadSummary[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; + isThreadListExpanded: boolean; + projectCwd: string; + activeRouteThreadKey: string | null; + openPullRequestsInRightPanel: boolean; + threadJumpLabelByKey: ReadonlyMap; + appSettingsConfirmThreadArchive: boolean; + renamingThreadKey: string | null; + renamingTitle: string; + setRenamingTitle: (title: string) => void; + startThreadRename: (threadKey: string, title: string) => void; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + handleThreadClick: ( + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], + ) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + handleThreadContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + ) => Promise; + clearSelection: () => void; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; + cancelRename: () => void; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: ( + event: React.MouseEvent, + prUrl: string, + threadRef?: ScopedThreadRef, + ) => boolean; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; +} + +const SidebarProjectThreadList = memo(function SidebarProjectThreadList( + props: SidebarProjectThreadListProps, +) { + const { + projectKey, + projectExpanded, + hasOverflowingThreads, + hiddenThreadStatus, + orderedProjectThreadKeys, + renderedThreads, + showEmptyThreadState, + shouldShowThreadPanel, + isThreadListExpanded, + projectCwd, + activeRouteThreadKey, + openPullRequestsInRightPanel, + threadJumpLabelByKey, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + startThreadRename, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + attachThreadListAutoAnimateRef, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + expandThreadListForProject, + collapseThreadListForProject, + } = props; + const showMoreButtonRender = useMemo(() => +
    + } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
    + + + + { + if (!open) { + closeProjectRenameDialog(); + } + }} + > + + + Rename project + + {projectRenameTarget + ? `Update the title for ${projectRenameTarget.workspaceRoot}.` + : "Update the project title."} + + + +
    + Project title + setProjectRenameTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitProjectRename(); + } + }} + /> +
    + {projectRenameTarget?.environmentLabel ? ( +

    + Environment: {projectRenameTarget.environmentLabel} +

    + ) : null} +
    + + + + +
    +
    + + { + if (!open) { + closeProjectGroupingDialog(); + } + }} + > + + + Project grouping + + {projectGroupingTarget + ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.` + : "Choose how this project should be grouped in the sidebar."} + + + +
    + Grouping rule + +
    +

    + {projectGroupingSelection === "inherit" + ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) + : projectGroupingModeDescription(projectGroupingSelection)} +

    +
    + + + + +
    +
    + + ); +}); + +const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { + return ( + + + + ); +}); + +function LocalSecondaryStatus() { + const { environments } = useEnvironments(); + // The desktop reports which local secondary backends (e.g. the WSL backend) + // exist; the hook polls because the bridge has no change event. A backend that + // is still cold-booting has no httpBaseUrl yet and isn't in the catalog, so we + // surface "Connecting" straight from the bootstrap list and clear it once the + // matching environment reports a connected phase. + const secondaries = useDesktopLocalBootstraps(); + + // Connected desktop-local environments keyed by their backend URL so we can + // match a bootstrap (which only knows the URL) to its connection phase. + const localEnvByUrl = useMemo(() => { + const map = new Map(); + for (const environment of environments) { + if ( + isDesktopLocalConnectionTarget(environment.entry.target) && + environment.displayUrl !== null + ) { + map.set(environment.displayUrl, { + phase: environment.connection.phase, + error: environment.connection.error, + }); + } + } + return map; + }, [environments]); + + const connecting: string[] = []; + const failed: Array<{ label: string; error: string | null }> = []; + for (const bootstrap of secondaries) { + const env = + bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined; + if (env?.phase === "connected") { + continue; + } + if (env?.phase === "error") { + failed.push({ label: bootstrap.label, error: env.error }); + continue; + } + connecting.push(bootstrap.label); + } + + if (connecting.length === 0 && failed.length === 0) { + return null; + } + + return ( + + {connecting.length > 0 ? ( + + + + Connecting {connecting.join(", ")} + + + ) : null} + {failed.length > 0 ? ( + + + Couldn't connect {failed.map((entry) => entry.label).join(", ")} + + {failed + .map((entry) => entry.error) + .filter(Boolean) + .join("; ") || "The backend didn't respond."} + + + ) : null} + + ); +} + +type SortableProjectHandleProps = Pick< + ReturnType, + "attributes" | "listeners" | "setActivatorNodeRef" +>; + +function ProjectSortMenu({ + projectSortOrder, + threadSortOrder, + threadPreviewCount, + onProjectSortOrderChange, + onThreadSortOrderChange, + onThreadPreviewCountChange, +}: { + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + threadPreviewCount: SidebarThreadPreviewCount; + onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; + onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; +}) { + const handleThreadPreviewCountChange = useCallback( + (nextValue: number | null) => { + if (nextValue === null) { + return; + } + + const clampedValue = clampSidebarThreadPreviewCount(nextValue); + if (clampedValue !== threadPreviewCount) { + onThreadPreviewCountChange(clampedValue); + } + }, + [onThreadPreviewCountChange, threadPreviewCount], + ); + + return ( + + + + } + > + + + Sidebar options + + + +
    + Sort projects +
    + { + onProjectSortOrderChange(value as SidebarProjectSortOrder); + }} + > + {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( + ([value, label]) => ( + + {label} + + ), + )} + +
    + +
    + Sort threads +
    + { + onThreadSortOrderChange(value as SidebarThreadSortOrder); + }} + > + {( + Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> + ).map(([value, label]) => ( + + {label} + + ))} + +
    + +
    + Visible threads +
    +
    + + + + { + event.stopPropagation(); + }} + /> + + + +
    +
    +
    +
    + ); +} + +function SortableProjectItem({ + projectId, + disabled = false, + children, +}: { + projectId: string; + disabled?: boolean; + children: (handleProps: SortableProjectHandleProps) => React.ReactNode; +}) { + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ id: projectId, disabled }); + return ( +
  1. + {children({ attributes, listeners, setActivatorNodeRef })} +
  2. + ); +} + +interface SidebarProjectsContentProps { + showArm64IntelBuildWarning: boolean; + arm64IntelBuildWarningDescription: string | null; + desktopUpdateButtonAction: "download" | "install" | "none"; + desktopUpdateButtonDisabled: boolean; + desktopUpdateActionPending: boolean; + handleDesktopUpdateButtonClick: () => void; + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + threadPreviewCount: SidebarThreadPreviewCount; + updateSettings: ReturnType; + openAddProject: () => void; + isManualProjectSorting: boolean; + projectDnDSensors: ReturnType; + projectCollisionDetection: CollisionDetection; + handleProjectDragStart: (event: DragStartEvent) => void; + handleProjectDragEnd: (event: DragEndEvent) => void; + handleProjectDragCancel: (event: DragCancelEvent) => void; + handleNewThread: ReturnType; + archiveThread: ReturnType["archiveThread"]; + deleteThread: ReturnType["deleteThread"]; + sortedProjects: readonly SidebarProjectSnapshot[]; + expandedThreadListsByProject: ReadonlySet; + activeRouteProjectKey: string | null; + routeThreadKey: string | null; + openPullRequestsInRightPanel: boolean; + newThreadShortcutLabel: string | null; + commandPaletteShortcutLabel: string | null; + threadJumpLabelByKey: ReadonlyMap; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; + projectsLength: number; +} + +const SidebarProjectsContent = memo(function SidebarProjectsContent( + props: SidebarProjectsContentProps, +) { + const { + showArm64IntelBuildWarning, + arm64IntelBuildWarningDescription, + desktopUpdateButtonAction, + desktopUpdateButtonDisabled, + desktopUpdateActionPending, + handleDesktopUpdateButtonClick, + projectSortOrder, + threadSortOrder, + threadPreviewCount, + updateSettings, + openAddProject, + isManualProjectSorting, + projectDnDSensors, + projectCollisionDetection, + handleProjectDragStart, + handleProjectDragEnd, + handleProjectDragCancel, + handleNewThread, + archiveThread, + deleteThread, + sortedProjects, + expandedThreadListsByProject, + activeRouteProjectKey, + routeThreadKey, + openPullRequestsInRightPanel, + newThreadShortcutLabel, + commandPaletteShortcutLabel, + threadJumpLabelByKey, + attachThreadListAutoAnimateRef, + expandThreadListForProject, + collapseThreadListForProject, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + attachProjectListAutoAnimateRef, + projectsLength, + } = props; + + const handleProjectSortOrderChange = useCallback( + (sortOrder: SidebarProjectSortOrder) => { + updateSettings({ sidebarProjectSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadSortOrderChange = useCallback( + (sortOrder: SidebarThreadSortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadPreviewCountChange = useCallback( + (count: SidebarThreadPreviewCount) => { + updateSettings({ sidebarThreadPreviewCount: count }); + }, + [updateSettings], + ); + + return ( + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + } + > + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( + + + + Intel build on Apple Silicon + {arm64IntelBuildWarningDescription} + {desktopUpdateButtonAction !== "none" ? ( + + + + ) : null} + + + ) : null} + + +
    + Projects +
    + + + + } + > + + + Add project + +
    +
    + + {isManualProjectSorting ? ( + + + project.projectKey)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => ( + + )} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + ))} + + )} + + {projectsLength === 0 && ( +
    No projects yet
    + )} +
    +
    + ); +}); + +export default function LegacySidebar() { + const projects = useProjects(); + const sidebarThreads = useThreadShells(); + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const navigate = useNavigate(); + const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const updateSettings = useUpdateClientSettings(); + const handleNewThread = useNewThreadHandler(); + const { archiveThread, deleteThread } = useThreadActions(); + const { isMobile, setOpenMobile } = useSidebar(); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], + ); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const openAddProjectCommandPalette = useCallback( + () => openCommandPalette({ open: "add-project" }), + [], + ); + const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< + ReadonlySet + >(() => new Set()); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const dragInProgressRef = useRef(false); + const suppressProjectClickAfterDragRef = useRef(false); + const suppressProjectClickForContextMenuRef = useRef(false); + const desktopUpdateState = useDesktopUpdateState(); + const [desktopUpdateActionPending, setDesktopUpdateActionPending] = useState(false); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const platform = navigator.platform; + const shortcutModifiers = useShortcutModifierState(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const desktopLocalEnvironmentIds = useMemo( + () => + new Set( + environments + .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) + .map((environment) => environment.environmentId), + ), + [environments], + ); + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }); + }, [projectOrder, projects]); + + // Build a mapping from physical project key → logical project key for + // cross-environment grouping. Projects that share a repositoryIdentity + // canonicalKey are treated as one logical project in the sidebar. + const physicalToLogicalKey = useMemo(() => { + return buildPhysicalToLogicalProjectKeyMap({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }); + }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); + const projectPhysicalKeyByScopedRef = useMemo( + () => + new Map( + orderedProjects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + derivePhysicalProjectKey(project), + ]), + ), + [orderedProjects], + ); + + const sidebarProjects = useMemo(() => { + return buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), + }); + }, [ + environmentLabelById, + desktopLocalEnvironmentIds, + orderedProjects, + projectGroupingSettings, + primaryEnvironmentId, + ]); + + const sidebarProjectByKey = useMemo( + () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + [sidebarProjects], + ); + const sidebarThreadByKey = useMemo( + () => + new Map( + sidebarThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [sidebarThreads], + ); + // Resolve the active route's project key to a logical key so it matches the + // sidebar's grouped project entries. + const activeRouteProjectKey = useMemo(() => { + if (!routeThreadKey) { + return null; + } + const activeThread = sidebarThreadByKey.get(routeThreadKey); + if (!activeThread) return null; + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); + return physicalToLogicalKey.get(physicalKey) ?? physicalKey; + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + + // Group threads by logical project key so all threads from grouped projects + // are displayed together. + const threadsByProjectKey = useMemo(() => { + const next = new Map(); + for (const thread of sidebarThreads) { + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const existing = next.get(logicalKey); + if (existing) { + existing.push(thread); + } else { + next.set(logicalKey, [thread]); + } + } + return next; + }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }), + [routeTerminalOpen], + ); + const newThreadShortcutLabelOptions = useMemo( + () => ({ + platform, + context: { + terminalFocus: false, + terminalOpen: false, + }, + }), + [platform], + ); + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? + shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); + + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor], + ); + + const projectDnDSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + ); + const projectCollisionDetection = useCallback((args) => { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + + return closestCorners(args); + }, []); + + const handleProjectDragEnd = useCallback( + (event: DragEndEvent) => { + if (sidebarProjectSortOrder !== "manual") { + dragInProgressRef.current = false; + return; + } + dragInProgressRef.current = false; + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); + const overProject = sidebarProjects.find((project) => project.projectKey === over.id); + if (!activeProject || !overProject) return; + const activeMemberKeys = activeProject.memberProjects.map( + (member) => member.physicalProjectKey, + ); + const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); + reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); + }, + [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects], + ); + + const handleProjectDragStart = useCallback( + (_event: DragStartEvent) => { + if (sidebarProjectSortOrder !== "manual") { + return; + } + dragInProgressRef.current = true; + suppressProjectClickAfterDragRef.current = true; + }, + [sidebarProjectSortOrder], + ); + + const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { + dragInProgressRef.current = false; + }, []); + + const animatedProjectListsRef = useRef(new WeakSet()); + const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedProjectListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedProjectListsRef.current.add(node); + }, []); + + const animatedThreadListsRef = useRef(new WeakSet()); + const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedThreadListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedThreadListsRef.current.add(node); + }, []); + + const visibleThreads = useMemo( + () => sidebarThreads.filter((thread) => thread.archivedAt === null), + [sidebarThreads], + ); + const sortedProjects = useMemo(() => { + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = visibleThreads.map((thread) => { + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + return { + ...thread, + projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, + }; + }); + return sortProjectsForSidebar( + sortableProjects, + sortableThreads, + sidebarProjectSortOrder, + ).flatMap((project) => { + const resolvedProject = sidebarProjectByKey.get(project.id); + return resolvedProject ? [resolvedProject] : []; + }); + }, [ + sidebarProjectSortOrder, + physicalToLogicalKey, + projectPhysicalKeyByScopedRef, + sidebarProjectByKey, + sidebarProjects, + visibleThreads, + ]); + const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjects.flatMap((project) => { + const projectThreads = sortThreads( + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + ); + const projectExpanded = resolveProjectExpanded( + projectExpandedById, + projectExpansionPreferenceKeys(project), + ); + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeThreadKey, + ) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); + const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; + const previewThreads = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreads + : projectThreads.slice(0, sidebarThreadPreviewCount); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + }), + [ + sidebarThreadSortOrder, + sidebarThreadPreviewCount, + expandedThreadListsByProject, + projectExpandedById, + routeThreadKey, + sortedProjects, + threadsByProjectKey, + ], + ); + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(threadKey, jumpCommand); + } + + return mapping; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const sidebarShortcutContext = { + terminalFocus: false, + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }; + const threadJumpLabelByKey = useMemo( + () => + buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: sidebarShortcutContext.terminalOpen, + threadJumpCommandByKey, + }), + [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey], + ); + const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, + keybindings, + { + platform, + context: sidebarShortcutContext, + }, + ); + const visibleThreadJumpLabelByKey = showThreadJumpHints + ? threadJumpLabelByKey + : EMPTY_THREAD_JUMP_LABELS; + const orderedSidebarThreadKeys = visibleSidebarThreadKeys; + const prewarmedSidebarThreadKeys = useMemo( + () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), + [visibleSidebarThreadKeys], + ); + const prewarmedSidebarThreadRefs = useMemo( + () => + prewarmedSidebarThreadKeys.flatMap((threadKey) => { + const ref = parseScopedThreadKey(threadKey); + return ref ? [ref] : []; + }), + [prewarmedSidebarThreadKeys], + ); + + useEffect(() => { + updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); + }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); + + useEffect(() => { + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + const shortcutContext = getCurrentSidebarShortcutContext(); + + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: shortcutContext, + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: orderedSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }); + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + }; + + window.addEventListener("keydown", onWindowKeyDown); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + }; + }, [ + getCurrentSidebarShortcutContext, + keybindings, + navigateToThread, + orderedSidebarThreadKeys, + platform, + routeThreadKey, + sidebarThreadByKey, + threadJumpThreadKeys, + ]); + + useEffect(() => { + const onMouseDown = (event: globalThis.MouseEvent) => { + if (!useThreadSelectionStore.getState().hasSelection()) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (!shouldClearThreadSelectionOnMouseDown(target)) return; + clearSelection(); + }; + + window.addEventListener("mousedown", onMouseDown); + return () => { + window.removeEventListener("mousedown", onMouseDown); + }; + }, [clearSelection]); + + const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); + const desktopUpdateButtonAction = desktopUpdateState + ? resolveDesktopUpdateButtonAction(desktopUpdateState) + : "none"; + const showArm64IntelBuildWarning = + isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); + const arm64IntelBuildWarningDescription = + desktopUpdateState && showArm64IntelBuildWarning + ? getArm64IntelBuildWarningDescription(desktopUpdateState) + : null; + const commandPaletteShortcutLabel = shortcutLabelForCommand( + keybindings, + "commandPalette.toggle", + newThreadShortcutLabelOptions, + ); + const handleDesktopUpdateButtonClick = useCallback(async () => { + const bridge = window.desktopBridge; + if (!bridge || !desktopUpdateState) return; + if ( + desktopUpdateButtonDisabled || + desktopUpdateButtonAction === "none" || + desktopUpdateActionPending + ) { + return; + } + + setDesktopUpdateActionPending(true); + + if (desktopUpdateButtonAction === "download") { + void bridge + .downloadUpdate() + .then((result) => { + if (result.completed) { + showDesktopUpdateDownloadedToast(bridge, result.state); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: actionError, + }), + ); + }) + .catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + }) + .finally(() => setDesktopUpdateActionPending(false)); + return; + } + + if (desktopUpdateButtonAction === "install") { + let confirmed = false; + try { + confirmed = await ensureLocalApi().dialogs.confirm( + getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), + ); + } catch (error) { + setDesktopUpdateActionPending(false); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not confirm update", + description: error instanceof Error ? error.message : "Update confirmation failed.", + }), + ); + return; + } + if (!confirmed) { + setDesktopUpdateActionPending(false); + return; + } + void bridge + .installUpdate() + .then((result) => { + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: actionError, + }), + ); + }) + .catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + }) + .finally(() => setDesktopUpdateActionPending(false)); + } + }, [ + desktopUpdateActionPending, + desktopUpdateButtonAction, + desktopUpdateButtonDisabled, + desktopUpdateState, + ]); + + const expandThreadListForProject = useCallback((projectKey: string) => { + setExpandedThreadListsByProject((current) => { + if (current.has(projectKey)) return current; + const next = new Set(current); + next.add(projectKey); + return next; + }); + }, []); + + const collapseThreadListForProject = useCallback((projectKey: string) => { + setExpandedThreadListsByProject((current) => { + if (!current.has(projectKey)) return current; + const next = new Set(current); + next.delete(projectKey); + return next; + }); + }, []); + + return ( + <> + {prewarmedSidebarThreadRefs.map((threadRef) => ( + + ))} + + + + + + ); +} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 68a5855c1a28..82dddd8f41e0 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -11,7 +11,9 @@ export function NoActiveThreadState() {
    diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx deleted file mode 100644 index abc0db79b6cb..000000000000 --- a/apps/web/src/components/PlanSidebar.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { memo, useState, useCallback } from "react"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; -import { type TimestampFormat } from "@t3tools/contracts/settings"; -import { Badge } from "./ui/badge"; -import { Button } from "./ui/button"; -import { ScrollArea } from "./ui/scroll-area"; -import ChatMarkdown from "./ChatMarkdown"; -import { - CheckIcon, - ChevronDownIcon, - ChevronRightIcon, - EllipsisIcon, - LoaderIcon, -} from "lucide-react"; -import { cn } from "~/lib/utils"; -import type { ActivePlanState } from "../session-logic"; -import type { LatestProposedPlanState } from "../session-logic"; -import { formatTimestamp } from "../timestampFormat"; -import { - proposedPlanTitle, - buildProposedPlanMarkdownFilename, - normalizePlanMarkdownForExport, - downloadPlanAsTextFile, - stripDisplayedPlanMarkdown, -} from "../proposedPlan"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { projectEnvironment } from "~/state/projects"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useAtomCommand } from "~/state/use-atom-command"; - -function stepStatusIcon(status: string): React.ReactNode { - if (status === "completed") { - return ( - - - - ); - } - if (status === "inProgress") { - return ( - - - - ); - } - return ( - - - - ); -} - -interface PlanSidebarProps { - activePlan: ActivePlanState | null; - activeProposedPlan: LatestProposedPlanState | null; - label?: string; - environmentId: EnvironmentId; - threadRef?: ScopedThreadRef | undefined; - markdownCwd: string | undefined; - workspaceRoot: string | undefined; - timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar" | "embedded"; -} - -const PlanSidebar = memo(function PlanSidebar({ - activePlan, - activeProposedPlan, - label = "Plan", - environmentId, - threadRef, - markdownCwd, - workspaceRoot, - timestampFormat, - mode = "sidebar", -}: PlanSidebarProps) { - const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); - const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { - reportFailure: false, - }); - const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); - - const planMarkdown = activeProposedPlan?.planMarkdown ?? null; - const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; - const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; - - const handleCopyPlan = useCallback(() => { - if (!planMarkdown) return; - copyToClipboard(planMarkdown); - }, [planMarkdown, copyToClipboard]); - - const handleDownload = useCallback(() => { - if (!planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); - }, [planMarkdown]); - - const handleSaveToWorkspace = useCallback(() => { - if (!workspaceRoot || !planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - setIsSavingToWorkspace(true); - void (async () => { - const result = await writeProjectFile({ - environmentId, - input: { - cwd: workspaceRoot, - relativePath: filename, - contents: normalizePlanMarkdownForExport(planMarkdown), - }, - }); - setIsSavingToWorkspace(false); - if (result._tag === "Success") { - toastManager.add({ - type: "success", - title: "Plan saved", - description: result.value.relativePath, - }); - return; - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]); - - return ( -
    - {/* Header */} -
    -
    - - {label} - - {activePlan ? ( - - {formatTimestamp(activePlan.createdAt, timestampFormat)} - - ) : null} -
    -
    - {planMarkdown ? ( - - - } - > - - - - - {isCopied ? "Copied!" : "Copy to clipboard"} - - Download as markdown - - Save to workspace - - - - ) : null} -
    -
    - - {/* Content */} - -
    - {/* Explanation */} - {activePlan?.explanation ? ( -

    - {activePlan.explanation} -

    - ) : null} - - {/* Plan Steps */} - {activePlan && activePlan.steps.length > 0 ? ( -
    -

    - Steps -

    - {activePlan.steps.map((step) => ( -
    - {stepStatusIcon(step.status)} -

    - {step.step} -

    -
    - ))} -
    - ) : null} - - {/* Proposed Plan Markdown */} - {planMarkdown ? ( -
    - - {proposedPlanExpanded ? ( -
    - -
    - ) : null} -
    - ) : null} - - {/* Empty state */} - {!activePlan && !planMarkdown ? ( -
    -

    No active plan yet.

    -

    - Plans will appear here when generated. -

    -
    - ) : null} -
    -
    -
    - ); -}); - -export default PlanSidebar; -export type { PlanSidebarProps }; diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index c2fac8beb7ec..bbeeda4bc7fb 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -4,6 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", + lastResource: null as unknown, })); const hooks = vi.hoisted(() => { @@ -52,7 +53,10 @@ vi.mock("react", async (importOriginal) => { vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); vi.mock("../assets/assetUrls", () => ({ - useAssetUrl: () => testState.faviconUrl, + useAssetUrlState: (_environmentId: unknown, resource: unknown) => { + testState.lastResource = resource; + return { _tag: "Success", url: testState.faviconUrl }; + }, })); import { ProjectFavicon } from "./ProjectFavicon"; @@ -125,4 +129,18 @@ describe("ProjectFavicon", () => { expect(afterDisplayedError[0]).not.toBeNull(); expect(afterDisplayedError[1]).toBeNull(); }); + + it("requests a saved favicon path when one is set", () => { + ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace-test", + faviconPath: "brand/icon.svg", + }); + + expect(testState.lastResource).toEqual({ + _tag: "project-favicon", + cwd: "/workspace-test", + path: "brand/icon.svg", + }); + }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 1df19a640756..619bbf370018 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -6,7 +6,7 @@ import { import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; -import { useAssetUrl } from "../assets/assetUrls"; +import { useAssetUrlState } from "../assets/assetUrls"; import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Map(); @@ -14,13 +14,12 @@ const loadedProjectFaviconSrcs = new Map(); export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; + faviconPath?: string | null | undefined; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const src = useAssetUrl(input.environmentId, { - _tag: "project-favicon", - cwd: input.cwd, - }); + const state = useProjectFaviconAsset(input); + const src = state._tag === "Success" ? state.url : null; const FallbackIcon = input.fallbackIcon ?? FolderIcon; if (!src || isProjectFaviconFallbackUrl(src)) { @@ -40,6 +39,18 @@ export function ProjectFavicon(input: { ); } +export function useProjectFaviconAsset(input: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly faviconPath?: string | null | undefined; +}) { + return useAssetUrlState(input.environmentId, { + _tag: "project-favicon", + cwd: input.cwd, + ...(input.faviconPath ? { path: input.faviconPath } : {}), + }); +} + function ProjectFaviconFallback({ className, icon: Icon, @@ -47,7 +58,7 @@ function ProjectFaviconFallback({ readonly className?: string | undefined; readonly icon: ComponentType<{ className?: string }>; }) { - return ; + return ; } function ProjectFaviconImage({ diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 3a1d71150988..304922909b0a 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,61 +1,28 @@ import type { ProjectScript, - ProjectScriptIcon, ResolvedKeybindingsConfig, T3ProjectFileScript, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, - type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { - BugIcon, - ChevronDownIcon, - DownloadIcon, - FlaskConicalIcon, - HammerIcon, - ListChecksIcon, - PlayIcon, - PlusIcon, - SettingsIcon, - WrenchIcon, -} from "lucide-react"; -import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; +import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; -import { - keybindingValueForCommand, - decodeProjectScriptKeybindingRule, -} from "~/lib/projectScriptKeybindings"; -import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; -import { - commandForProjectScript, - nextProjectScriptId, - primaryProjectScript, -} from "~/projectScripts"; +import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from "./ui/alert-dialog"; + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + ScriptIcon, + type NewProjectScriptInput, + type ProjectScriptActionResult, + type ProjectScriptEditorRequest, +} from "./projectScriptEditor"; import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; import { Group, GroupSeparator } from "./ui/group"; -import { Input } from "./ui/input"; -import { Label } from "./ui/label"; import { Menu, MenuGroup, @@ -66,48 +33,9 @@ import { MenuShortcut, MenuTrigger, } from "./ui/menu"; -import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; -import { Switch } from "./ui/switch"; -import { Textarea } from "./ui/textarea"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ - { id: "play", label: "Play" }, - { id: "test", label: "Test" }, - { id: "lint", label: "Lint" }, - { id: "configure", label: "Configure" }, - { id: "build", label: "Build" }, - { id: "debug", label: "Debug" }, -]; - -function ScriptIcon({ - icon, - className = "size-3.5", -}: { - icon: ProjectScriptIcon; - className?: string; -}) { - if (icon === "test") return ; - if (icon === "lint") return ; - if (icon === "configure") return ; - if (icon === "build") return ; - if (icon === "debug") return ; - return ; -} - -export interface NewProjectScriptInput { - name: string; - command: string; - icon: ProjectScriptIcon; - runOnWorktreeCreate: boolean; - keybinding: string | null; - /** Optional URL to open in the in-app preview when this script runs. */ - previewUrl: string | null; - /** When true, automatically open the preview panel pointed at `previewUrl`. */ - autoOpenPreview: boolean; -} - -export type ProjectScriptActionResult = AtomCommandResult; +export type { NewProjectScriptInput, ProjectScriptActionResult }; const NO_FILE_SCRIPTS: ReadonlyArray = []; @@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ onUpdateScript, onDeleteScript, }: ProjectScriptsControlProps) { - const addScriptFormId = React.useId(); - const [editingScriptId, setEditingScriptId] = useState(null); const [actionsMenuOpen, setActionsMenuOpen] = useState({ scripts: false, imports: false, }); - const [dialogOpen, setDialogOpen] = useState(false); - const [name, setName] = useState(""); - const [command, setCommand] = useState(""); - const [icon, setIcon] = useState("play"); - const [iconPickerOpen, setIconPickerOpen] = useState(false); - const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); - const [keybinding, setKeybinding] = useState(""); - const [previewUrl, setPreviewUrl] = useState(""); - const [autoOpenPreview, setAutoOpenPreview] = useState(false); - const [validationError, setValidationError] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [editorRequest, setEditorRequest] = useState(null); const primaryScript = useMemo(() => { if (preferredScriptId) { @@ -173,112 +89,23 @@ export default function ProjectScriptsControl({ ), [fileScripts, scripts], ); - const isEditing = editingScriptId !== null; const dropdownItemClassName = "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground"; - const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; - event.preventDefault(); - if (event.key === "Backspace" || event.key === "Delete") { - setKeybinding(""); - return; - } - const next = keybindingFromKeyboardEvent(event, navigator.platform); - if (!next) return; - setKeybinding(next); - }; - - const submitAddScript = async (event: FormEvent) => { - event.preventDefault(); - const trimmedName = name.trim(); - const trimmedCommand = command.trim(); - if (trimmedName.length === 0) { - setValidationError("Name is required."); - return; - } - if (trimmedCommand.length === 0) { - setValidationError("Command is required."); - return; - } - - setValidationError(null); - let payload: NewProjectScriptInput; - try { - const scriptIdForValidation = - editingScriptId ?? - nextProjectScriptId( - trimmedName, - scripts.map((script) => script.id), - ); - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: commandForProjectScript(scriptIdForValidation), - }); - const trimmedPreviewUrl = previewUrl.trim(); - payload = { - name: trimmedName, - command: trimmedCommand, - icon, - runOnWorktreeCreate, - keybinding: keybindingRule?.key ?? null, - previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, - autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, - } satisfies NewProjectScriptInput; - } catch (error) { - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - return; - } - - const result = editingScriptId - ? await onUpdateScript(editingScriptId, payload) - : await onAddScript(payload); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - } - return; - } - setDialogOpen(false); - setIconPickerOpen(false); - }; - const openAddDialog = () => { - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setIconPickerOpen(false); - setRunOnWorktreeCreate(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }); }; const openEditDialog = (script: ProjectScript) => { setActionsMenuOpen({ scripts: false, imports: false }); - setEditingScriptId(script.id); - setName(script.name); - setCommand(script.command); - setIcon(script.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(script.runOnWorktreeCreate); - setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); - setPreviewUrl(script.previewUrl ?? ""); - setAutoOpenPreview(script.autoOpenPreview ?? false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest(editorRequestForScript(script, keybindings)); }; - const confirmDeleteScript = useCallback(() => { - if (!editingScriptId) return; - setDeleteConfirmOpen(false); - setDialogOpen(false); - void onDeleteScript(editingScriptId); - }, [editingScriptId, onDeleteScript]); + const submitScript = useCallback( + (scriptId: string | null, input: NewProjectScriptInput) => + scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input), + [onAddScript, onUpdateScript], + ); const importFileScript = async (fileScript: T3ProjectFileScript) => { const payload: NewProjectScriptInput = { @@ -295,17 +122,11 @@ export default function ProjectScriptsControl({ // Surface the failure through the regular add dialog, prefilled so the // user can adjust and retry. const error = squashAtomCommandFailure(result); - setEditingScriptId(null); - setName(payload.name); - setCommand(payload.command); - setIcon(payload.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(payload.runOnWorktreeCreate); - setKeybinding(""); - setPreviewUrl(payload.previewUrl ?? ""); - setAutoOpenPreview(payload.autoOpenPreview); - setValidationError(error instanceof Error ? error.message : "Failed to import action."); - setDialogOpen(true); + setEditorRequest({ + scriptId: null, + initial: payload, + error: error instanceof Error ? error.message : "Failed to import action.", + }); } }; @@ -343,6 +164,9 @@ export default function ProjectScriptsControl({ variant="outline" className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" aria-label={`Run ${primaryScript.name}`} + // The tooltip wrapper replaces data-slot="button", so themed + // toolbar styling needs its own hook. + data-toolbar-control="" onClick={() => onRunScript(primaryScript)} /> } @@ -447,6 +271,9 @@ export default function ProjectScriptsControl({ variant="outline" className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" aria-label="Add action" + // The tooltip wrapper replaces data-slot="button", so themed + // toolbar styling needs its own hook. + data-toolbar-control="" onClick={openAddDialog} /> } @@ -460,184 +287,13 @@ export default function ProjectScriptsControl({ )} - { - setDialogOpen(open); - if (!open) { - setIconPickerOpen(false); - } - }} - onOpenChangeComplete={(open) => { - if (open) return; - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setRunOnWorktreeCreate(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - }} - open={dialogOpen} - > - - - {isEditing ? "Edit Action" : "Add Action"} - - Actions are project-scoped commands you can run from the top bar or keybindings. - - - -
    -
    - -
    - - - } - > - - - -
    - {SCRIPT_ICONS.map((entry) => { - const isSelected = entry.id === icon; - return ( - - ); - })} -
    -
    -
    - setName(event.target.value)} - /> -
    -
    -
    - - -

    - Press a shortcut. Use Backspace to clear. -

    -
    -
    - -