diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index f48bb0377ad94..306038007e9a0 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -36,8 +36,9 @@ vp run mobile:install This installs the dev client for the checkout's native fingerprint: from the device, the local cache, or the shared cache in seconds, building once only -when no machine has that fingerprint yet (5-15 minutes, logged to the file it -names). The device id may be omitted when exactly one device is booted. It +when no machine has that fingerprint yet: in the `Mobile Dev Client` workflow +on Blacksmith when HEAD is pushed and `gh` may dispatch it (the command waits), +otherwise here (5-15 minutes, logged to the file it names). The device id may be omitted when exactly one device is booted. It prints one JSON line; on failure it names the next command. Mobile verification includes that step unless the user prohibits it. diff --git a/.github/actions/ci-changes/action.yml b/.github/actions/ci-changes/action.yml index ecf787404657a..7d2adc0c9e035 100644 --- a/.github/actions/ci-changes/action.yml +++ b/.github/actions/ci-changes/action.yml @@ -5,6 +5,8 @@ outputs: value: ${{ steps.detect.outputs.typecheck }} build: value: ${{ steps.detect.outputs.build }} + desktop_launch: + value: ${{ steps.detect.outputs.desktop_launch }} test: value: ${{ steps.detect.outputs.test }} test_packages: diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index 943bf116909d2..95581e2b22781 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -40,42 +40,67 @@ async function assertReleaseSource({ github, context, releaseChannel }) { const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v"); -async function findLatestNightly({ github, context }) { - const releases = await github.paginate(github.rest.repos.listReleases, { - ...context.repo, - per_page: 100, - }); - return releases - .filter((release) => !release.draft && release.published_at && isNightlyTag(release.tag_name)) - .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; +// Only the three fields the gate reads. The REST list also returns every asset, +// which made a full scan tens of megabytes. +const RELEASES_QUERY = ` + query ($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + releases(first: 100, after: $cursor, orderBy: { field: CREATED_AT, direction: DESC }) { + pageInfo { hasNextPage endCursor } + nodes { tagName isDraft publishedAt } + } + } + } +`; + +// Returns the nightly with the newest publishedAt. Release order follows the +// tagged commit date, not publication, so every page is read unless `isSettled` +// says the newest nightly seen so far already decides the caller's answer. +async function findLatestNightly({ github, context, isSettled = () => false }) { + let latest; + let cursor = null; + do { + const { repository } = await github.graphql(RELEASES_QUERY, { ...context.repo, cursor }); + const { nodes, pageInfo } = repository.releases; + for (const release of nodes) { + if (release.isDraft || !release.publishedAt || !isNightlyTag(release.tagName)) continue; + if (!latest || Date.parse(release.publishedAt) > Date.parse(latest.publishedAt)) { + latest = release; + } + } + if (latest && isSettled(latest)) return latest; + cursor = pageInfo.hasNextPage ? pageInfo.endCursor : null; + } while (cursor); + return latest; } async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { - const lastNightly = await findLatestNightly({ github, context }); + const isInGap = (release) => now - Date.parse(release.publishedAt) < MINIMUM_RELEASE_GAP_MS; + // Any nightly inside the gap means the newest one is too, so stop reading. + const lastNightly = await findLatestNightly({ github, context, isSettled: isInGap }); if (!lastNightly) { core.info("No published nightly found. Proceeding with release."); return true; } - if (now - Date.parse(lastNightly.published_at) < MINIMUM_RELEASE_GAP_MS) { - core.info(`Nightly ${lastNightly.tag_name} was published less than six hours ago. Skipping.`); + const tag = lastNightly.tagName; + if (isInGap(lastNightly)) { + core.info(`Nightly ${tag} was published less than six hours ago. Skipping.`); return false; } const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ ...context.repo, - basehead: `${lastNightly.tag_name}...${context.sha}`, + basehead: `${tag}...${context.sha}`, per_page: 1, }); if (comparison.status !== "ahead") { - core.info( - `Candidate commit is ${comparison.status} relative to ${lastNightly.tag_name}. Skipping.`, - ); + core.info(`Candidate commit is ${comparison.status} relative to ${tag}. Skipping.`); return false; } - core.info(`New commits since ${lastNightly.tag_name}, and the six-hour gap has passed.`); + core.info(`New commits since ${tag}, and the six-hour gap has passed.`); return true; } @@ -85,7 +110,7 @@ async function resolveLatestNightlyCommit({ github, context, core }) { throw new Error("No published nightly found. Stable releases build the latest nightly commit."); } - const tag = lastNightly.tag_name; + const tag = lastNightly.tagName; const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag }); const version = /^(?:nightly-)?v(\d+\.\d+\.\d+)-nightly\./.exec(tag)?.[1]; if (!version) { diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index f55d29c10dc6a..3030080d946ce 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -9,9 +9,9 @@ const { const now = Date.parse("2026-09-05T12:00:00Z"); const hour = 60 * 60 * 1000; const nightly = (hoursAgo, overrides = {}) => ({ - tag_name: "v1.0.1-nightly.20260905.123", - draft: false, - published_at: new Date(now - hoursAgo * hour).toISOString(), + tagName: "v1.0.1-nightly.20260905.123", + isDraft: false, + publishedAt: new Date(now - hoursAgo * hour).toISOString(), ...overrides, }); @@ -73,10 +73,13 @@ for (const comparisonStatus of ["behind", "diverged"]) { }); } -function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { +// `pages` holds the GraphQL release pages; `releases` is shorthand for one page. +function fixture({ releases = [nightly(7)], pages = [releases], comparisonStatus = "ahead" } = {}) { const calls = []; + const cursors = []; return { calls, + cursors, options: { now, context: { repo: { owner: "example", repo: "app" }, sha: "new" }, @@ -84,15 +87,25 @@ function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { github: { rest: { repos: { - listReleases() {}, async compareCommitsWithBasehead(params) { calls.push(params); return { data: { status: comparisonStatus } }; }, }, }, - async paginate() { - return releases; + async graphql(_query, { owner, repo, cursor }) { + assert.deepEqual({ owner, repo }, { owner: "example", repo: "app" }); + cursors.push(cursor); + const index = cursor === null ? 0 : Number(cursor); + const hasNextPage = index + 1 < pages.length; + return { + repository: { + releases: { + nodes: pages[index], + pageInfo: { hasNextPage, endCursor: hasNextPage ? String(index + 1) : null }, + }, + }, + }; }, }, }, @@ -101,28 +114,65 @@ function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { test("releases the first nightly when no nightly is published", async () => { const { options } = fixture({ - releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true })], + releases: [nightly(0, { tagName: "v1.0.0" }), nightly(0, { isDraft: true })], }); assert.equal(await shouldReleaseNightly(options), true); }); test("uses publication time, not release order or the tagged commit date", async () => { const { options } = fixture({ - releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })], + releases: [nightly(10), nightly(1), nightly(20, { tagName: "nightly-v0.9.0" })], + }); + assert.equal(await shouldReleaseNightly(options), false); +}); + +test("the six-hour gap ends exactly six hours after publication", async () => { + const atBoundary = fixture({ releases: [nightly(6)] }); + assert.equal(await shouldReleaseNightly(atBoundary.options), true); + const justInside = fixture({ releases: [nightly(6 - 1 / 3_600_000)] }); + assert.equal(await shouldReleaseNightly(justInside.options), false); + assert.equal(justInside.calls.length, 0); +}); + +test("stops reading releases once a nightly inside the gap is seen", async () => { + const { options, cursors } = fixture({ pages: [[nightly(10), nightly(2)], [nightly(30)]] }); + assert.equal(await shouldReleaseNightly(options), false); + assert.deepEqual(cursors, [null]); +}); + +test("reads every page when no nightly seen so far settles the gap", async () => { + const { options, cursors, calls } = fixture({ + pages: [ + [nightly(10), nightly(0, { tagName: "v1.0.0" })], + [nightly(1, { tagName: "nightly-v0.9.0" })], + ], }); assert.equal(await shouldReleaseNightly(options), false); + assert.deepEqual(cursors, [null, "1"]); + assert.equal(calls.length, 0); +}); + +test("compares against the newest nightly across pages once the gap has passed", async () => { + const { options, calls } = fixture({ + pages: [[nightly(10, { tagName: "v1.0.1-nightly.20260905.100" })], [nightly(8)]], + }); + assert.equal(await shouldReleaseNightly(options), true); + assert.deepEqual( + calls.map((call) => call.basehead), + ["v1.0.1-nightly.20260905.123...new"], + ); }); test("ignores stable releases and drafts when checking the gap", async () => { const { options } = fixture({ - releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true }), nightly(7)], + releases: [nightly(0, { tagName: "v1.0.0" }), nightly(0, { isDraft: true }), nightly(7)], }); assert.equal(await shouldReleaseNightly(options), true); }); test("fails instead of releasing when GitHub cannot supply release state", async () => { const { options } = fixture(); - options.github.paginate = async () => { + options.github.graphql = async () => { throw new Error("GitHub unavailable"); }; await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/); @@ -137,9 +187,9 @@ for (const status of ["behind", "diverged"]) { const { resolveLatestNightlyCommit } = require("./check-nightly-release.cjs"); -function nightlyCommitFixture({ releases, commitSha = "abc123" }) { +function nightlyCommitFixture({ releases, pages, commitSha = "abc123" }) { const refs = []; - const { options } = fixture({ releases }); + const { options } = fixture({ releases, pages }); options.github.rest.repos.getCommit = async ({ ref }) => { refs.push(ref); return { data: { sha: commitSha } }; @@ -150,10 +200,10 @@ function nightlyCommitFixture({ releases, commitSha = "abc123" }) { test("stable releases resolve the commit of the newest published nightly", async () => { const { options, refs } = nightlyCommitFixture({ releases: [ - nightly(10, { tag_name: "v1.0.1-nightly.20260905.100" }), - nightly(1, { tag_name: "v1.0.1-nightly.20260905.123" }), - nightly(0, { tag_name: "v1.0.0" }), - nightly(0, { draft: true, tag_name: "v1.0.1-nightly.20260905.999" }), + nightly(10, { tagName: "v1.0.1-nightly.20260905.100" }), + nightly(1, { tagName: "v1.0.1-nightly.20260905.123" }), + nightly(0, { tagName: "v1.0.0" }), + nightly(0, { isDraft: true, tagName: "v1.0.1-nightly.20260905.999" }), ], commitSha: "deadbeef", }); @@ -167,7 +217,23 @@ test("stable releases resolve the commit of the newest published nightly", async test("stable releases derive the version from legacy nightly tags", async () => { const { options } = nightlyCommitFixture({ - releases: [nightly(1, { tag_name: "nightly-v0.9.0-nightly.20260905.5" })], + releases: [nightly(1, { tagName: "nightly-v0.9.0-nightly.20260905.5" })], }); assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0"); }); + +test("stable releases read every page, even after a recent nightly", async () => { + const { options, refs } = nightlyCommitFixture({ + pages: [ + [nightly(2, { tagName: "v1.0.1-nightly.20260905.100" })], + [nightly(1, { tagName: "v1.0.1-nightly.20260905.123" })], + ], + }); + assert.equal((await resolveLatestNightlyCommit(options)).tag, "v1.0.1-nightly.20260905.123"); + assert.deepEqual(refs, ["v1.0.1-nightly.20260905.123"]); +}); + +test("stable releases fail when no nightly is published", async () => { + const { options } = nightlyCommitFixture({ releases: [nightly(1, { tagName: "v1.0.0" })] }); + await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly found/); +}); diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index ed32670755ff6..7b7332903ed9f 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -51,6 +51,21 @@ const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] }; const MOBILE_NATIVE = /^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//; +// Startup, migration, settlement, and sidebar paths that decide whether an upgraded user's +// threads show after launch. Build runs scripts/smoke-desktop-app.ts for them, so they do not +// first fail in the release launch check. +const DESKTOP_LAUNCH = [ + /^apps\/desktop\/src\/((main|boot|compileCache|serverCompileCache)\.ts$|(app|backend|window)\/)/, + /^apps\/desktop\/src\/electron\/(ElectronApp|ElectronProtocol|ElectronWindow)\.ts$/, + /^apps\/web\/src\/((main|AppRoot|appInit)\.tsx|(bootstrap|router)\.ts)$/, + /^apps\/web\/src\/.*sidebar/i, + /^apps\/server\/src\/(bin|binMain|server|serverRuntimeStartup)\.ts$/, + /^apps\/server\/src\/persistence\/Migrations/, + /^apps\/server\/src\/orchestration-v2\/((ThreadSettlementService|ProjectionStore)\.ts$|legacy\/)/, + /^packages\/contracts\/src\/settings\.ts$/, + /^scripts\/(smoke-desktop-app\.ts|fixtures\/desktop-launch\/)/, +]; + const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]); function readWorkspace() { @@ -151,12 +166,18 @@ function decide(files, packages, full) { (pkg) => pkg.hasTest && !OWN_TEST_JOB.has(pkg.name) && affected.has(pkg.name), ); const has = (name) => affected.has(name); + const desktopLaunch = + everything || + files.some( + (file) => !/\.test\.tsx?$/.test(file) && DESKTOP_LAUNCH.some((pattern) => pattern.test(file)), + ); return { affected: [...affected].sort(), outputs: { typecheck: affected.size > 0, - build: has("@t3tools/desktop") || has("t3") || has("@t3tools/web"), + build: has("@t3tools/desktop") || has("t3") || has("@t3tools/web") || desktopLaunch, + desktop_launch: desktopLaunch, test: tested.length > 0, // Empty means every package the Test job covers. test_packages: everything ? "" : tested.map((pkg) => pkg.name).join(" "), diff --git a/.github/scripts/ci-changes.d.cts b/.github/scripts/ci-changes.d.cts index afe6f61339362..4640e89776b0d 100644 --- a/.github/scripts/ci-changes.d.cts +++ b/.github/scripts/ci-changes.d.cts @@ -16,6 +16,7 @@ export function decide( outputs: { typecheck: boolean; build: boolean; + desktop_launch: boolean; test: boolean; test_packages: string; test_web: boolean; diff --git a/.github/scripts/mobile-preview-comment.cjs b/.github/scripts/mobile-preview-comment.cjs new file mode 100644 index 0000000000000..e4876b0842eaa --- /dev/null +++ b/.github/scripts/mobile-preview-comment.cjs @@ -0,0 +1,126 @@ +// The Android half of the mobile preview comment. The android job writes the fingerprint and +// the update; the android-build job adds the client's install link a few minutes later. Each +// run merges what it knows into the state kept in the comment's first line. +const MARKER = "mobile-preview-android"; +const PROJECT_ID = "d763fcb8-d37c-41ea-a773-b54a0ab4a454"; +const APP_SCHEME = "t3-code"; + +function readState(body) { + const match = new RegExp(`^`).exec(body ?? ""); + if (!match) return null; + try { + const state = JSON.parse(match[1]); + const text = (value) => (typeof value === "string" ? value : ""); + return { + fingerprint: text(state.fingerprint), + client: text(state.client), + group: text(state.group), + }; + } catch { + return null; + } +} + +/** + * New values win; a new fingerprint drops the old fingerprint's client. A build that finishes + * after the PR moved to another fingerprint changes nothing: its client cannot run the update + * the comment now shows. + */ +function mergeState(previous, next) { + const buildOnly = Boolean(next.client) && !next.group; + if ( + buildOnly && + previous?.fingerprint && + next.fingerprint && + previous.fingerprint !== next.fingerprint + ) { + return previous; + } + const fingerprint = next.fingerprint || previous?.fingerprint || ""; + const sameFingerprint = !previous?.fingerprint || previous.fingerprint === fingerprint; + return { + fingerprint, + client: next.client || (sameFingerprint ? (previous?.client ?? "") : ""), + group: next.group || previous?.group || "", + }; +} + +function render(state, runUrl) { + const qr = `https://qr.expo.dev/eas-update?appScheme=${APP_SCHEME}&projectId=${PROJECT_ID}&groupId=${state.group}`; + const rows = [ + ``, + "🤖 Android preview", + "", + "  | 🤖 Android", + "--- | ---", + `Fingerprint | ${state.fingerprint || "n/a"}`, + `Dev client | ${state.client ? `[Install](${state.client})` : `Building on Blacksmith ([run](${runUrl})); this comment gets the install link when it finishes`}`, + ]; + if (state.group) { + rows.push( + `Update Details | [Update Permalink](https://expo.dev/projects/${PROJECT_ID}/updates/${state.group})`, + `Update QR | `, + ); + } + return rows.join("\n"); +} + +async function github(path, init = {}) { + const response = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + authorization: `Bearer ${process.env.GH_TOKEN}`, + accept: "application/vnd.github+json", + "content-type": "application/json", + ...init.headers, + }, + }); + if (!response.ok) { + throw new Error( + `GitHub ${init.method ?? "GET"} ${path}: ${response.status} ${await response.text()}`, + ); + } + return response.json(); +} + +async function main() { + const repository = process.env.GITHUB_REPOSITORY; + const pr = process.env.PR; + if (!repository || !pr || !process.env.GH_TOKEN) { + throw new Error("GITHUB_REPOSITORY, PR and GH_TOKEN are required."); + } + const runUrl = `${process.env.GITHUB_SERVER_URL}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}`; + let existing = null; + for (let page = 1; existing === null; page++) { + const comments = await github( + `/repos/${repository}/issues/${pr}/comments?per_page=100&page=${page}`, + ); + // Only this workflow's own comment: anyone can post the marker, and nobody else's is editable. + existing = + comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && readState(comment.body) !== null, + ) ?? null; + if (comments.length < 100) break; + } + const state = mergeState(readState(existing?.body), { + fingerprint: process.env.FINGERPRINT ?? "", + client: process.env.CLIENT ?? "", + group: process.env.GROUP ?? "", + }); + const body = JSON.stringify({ body: render(state, runUrl) }); + if (existing) { + await github(`/repos/${repository}/issues/comments/${existing.id}`, { method: "PATCH", body }); + } else { + await github(`/repos/${repository}/issues/${pr}/comments`, { method: "POST", body }); + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} + +module.exports = { mergeState, readState, render }; diff --git a/.github/scripts/mobile-preview-comment.test.cjs b/.github/scripts/mobile-preview-comment.test.cjs new file mode 100644 index 0000000000000..99bfc45d4d2b9 --- /dev/null +++ b/.github/scripts/mobile-preview-comment.test.cjs @@ -0,0 +1,62 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { mergeState, readState, render } = require("./mobile-preview-comment.cjs"); + +const fingerprint = "c31608b37a8a2dca35ccee39cfd2ec7305042629"; +const run = "https://github.com/pingdotgg/t3code/actions/runs/1"; + +test("the comment round-trips its state", () => { + const state = { fingerprint, client: "", group: "8e413a0a" }; + const body = render(state, run); + assert.deepEqual(readState(body), state); + assert.match( + body, + /Building on Blacksmith \(\[run\]\(https:\/\/github\.com\/pingdotgg\/t3code\/actions\/runs\/1\)\)/, + ); + assert.match( + body, + /qr\.expo\.dev\/eas-update\?appScheme=t3-code&projectId=[0-9a-f-]+&groupId=8e413a0a/, + ); + assert.equal(readState("🚀 Expo continuous deployment is ready!"), null); +}); + +test("the build job adds the install link without losing the update", () => { + const first = mergeState(null, { fingerprint, client: "", group: "8e413a0a" }); + const built = mergeState(first, { fingerprint, client: "https://expo.dev/a/b", group: "" }); + assert.deepEqual(built, { fingerprint, client: "https://expo.dev/a/b", group: "8e413a0a" }); + assert.match(render(built, run), /Dev client \| \[Install\]\(https:\/\/expo\.dev\/a\/b\)/); +}); + +test("a new fingerprint drops the old client; the same one keeps it", () => { + const built = { fingerprint, client: "https://expo.dev/a/b", group: "old" }; + assert.deepEqual(mergeState(built, { fingerprint, client: "", group: "new" }), { + fingerprint, + client: "https://expo.dev/a/b", + group: "new", + }); + assert.deepEqual(mergeState(built, { fingerprint: "f".repeat(40), client: "", group: "new" }), { + fingerprint: "f".repeat(40), + client: "", + group: "new", + }); +}); + +test("a build that finishes after the PR moved to another fingerprint changes nothing", () => { + const current = { fingerprint: "f".repeat(40), client: "", group: "new" }; + assert.deepEqual( + mergeState(current, { fingerprint, client: "https://expo.dev/a/b", group: "" }), + current, + ); +}); + +test("state from the comment is always three strings", () => { + assert.deepEqual( + readState('\nx'), + { + fingerprint: "", + client: "", + group: "", + }, + ); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 417f0a95e8552..260be6436c3e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: outputs: typecheck: ${{ steps.detect.outputs.typecheck }} build: ${{ steps.detect.outputs.build }} + desktop_launch: ${{ steps.detect.outputs.desktop_launch }} test: ${{ steps.detect.outputs.test }} test_packages: ${{ steps.detect.outputs.test_packages }} test_web: ${{ steps.detect.outputs.test_web }} @@ -135,13 +136,14 @@ jobs: if: needs.changes.outputs.build != 'false' # Runs in the background while Vite+ installs; the step before the build waits for it. + # Xvfb is for the desktop launch check below. - name: Start installing browser secret helper build libraries if: needs.changes.outputs.build != 'false' run: | sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources ( set +e - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config xvfb echo "$?" > "$RUNNER_TEMP/apt-status" ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & @@ -192,6 +194,25 @@ jobs: if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: node apps/desktop/scripts/verify-preload-bundle.mjs + # The release launch check, run unpackaged against the same old-state fixture, for + # startup and sidebar changes. Packaging and signing stay release-only. + - name: Open the seeded thread in the built desktop app + id: desktop_launch + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.desktop_launch != 'false' }} + timeout-minutes: 4 + run: | + electron=$(node -p "require('./apps/desktop/node_modules/electron')") + xvfb-run -a node scripts/smoke-desktop-app.ts --executable "$electron" --app apps/desktop \ + --artifacts-dir "$RUNNER_TEMP/desktop-launch-check" + + - name: Upload desktop launch check + if: ${{ failure() && steps.desktop_launch.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: desktop-launch-check + path: ${{ runner.temp }}/desktop-launch-check + if-no-files-found: ignore + # Browser tests run source modules; this boots the production chunk graph. The # desktop build above already produced apps/web/dist, and the client waits about # 15s for a session that never comes, so this runs here instead of in Test Web, @@ -293,7 +314,7 @@ jobs: run: python3 -B .github/scripts/stage-preview-bundle.test.py - name: Test release checks - run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs + run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs .github/scripts/mobile-preview-comment.test.cjs - name: Finish installing browser secret helper build libraries timeout-minutes: 5 @@ -571,7 +592,7 @@ jobs: needs: changes if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 @@ -622,7 +643,7 @@ jobs: # not needed. Gates are keyed by job name, except merged Build and the # macOS lint. echo "$RESULTS" | jq -e '.changes.outputs as $gates - | {build: ["build", "typecheck"], mobile_native_static_analysis: ["mobile_native"]} as $renamed + | {build: ["build", "typecheck", "desktop_launch"], mobile_native_static_analysis: ["mobile_native"]} as $renamed | all(to_entries[]; .value.result == "success" or (.value.result == "skipped" diff --git a/.github/workflows/mobile-dev-client.yml b/.github/workflows/mobile-dev-client.yml new file mode 100644 index 0000000000000..5f8cb86befb59 --- /dev/null +++ b/.github/workflows/mobile-dev-client.yml @@ -0,0 +1,315 @@ +name: Mobile Dev Client +run-name: ${{ github.event_name == 'workflow_dispatch' && format('Dev client {0}{1} {2}', inputs.platform, inputs.platform == 'android' && format(' {0}', inputs.abi) || '', inputs.fingerprint) || '' }} + +# Builds the simulator and emulator development client for a commit's native +# fingerprint and shares it through the native-client cache, the one +# `vp run mobile:install` reads on every machine. Android builds with Gradle, +# ccache, and pnpm state on a sticky disk; iOS builds on a macOS runner, so no +# machine needs Xcode or an EAS build for it. +# +# - push to main: main's client is cached before anyone asks for it. +# - workflow_dispatch: `mobile:install` starts this for the checkout's HEAD on a +# cache miss, when the person running it may dispatch workflows here, and +# waits for the client in the cache. The run name is how it finds its run. +# - pull_request (this file or the script): builds without sharing, as a check. +on: + push: + branches: [main] + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - patches/** + - scripts/mobile-native-client.ts + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/mobile-dev-client.yml + pull_request: + paths: + - .github/workflows/mobile-dev-client.yml + - scripts/mobile-native-client.ts + workflow_dispatch: + inputs: + ref: + description: Commit to build (default main) + required: false + type: string + platform: + description: Platform + required: true + default: android + type: choice + options: [android, ios] + abi: + description: Android ABI of the device + required: true + default: x86_64 + type: choice + options: [x86_64, arm64-v8a] + fingerprint: + description: Native fingerprint the caller expects; the run fails when the commit's differs + required: false + type: string + +permissions: + contents: read + +jobs: + android: + name: Android ${{ matrix.abi }} + if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'workflow_dispatch' || inputs.platform == 'android') + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + # Pool emulators are x86_64; a dispatch builds the ABI it asks for. + abi: ${{ github.event_name == 'workflow_dispatch' && fromJSON(format('["{0}"]', inputs.abi)) || fromJSON('["x86_64"]') }} + concurrency: + # One run per client: a second request for the same fingerprint waits and finds it + # in the cache. A shared group would drop every waiting run but the newest. + group: mobile-dev-client-android-${{ matrix.abi }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.fingerprint || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: + NODE_OPTIONS: --max-old-space-size=8192 + ABI: ${{ matrix.abi }} + EXPECTED_FINGERPRINT: ${{ inputs.fingerprint }} + # A pull request checks that the build works: it builds even on a cache hit and shares nothing. + T3_NATIVE_CLIENT_CACHE: ${{ github.event_name == 'pull_request' && 'off' || '' }} + # Gradle and Xcode output goes to the job log, not a file on the runner. + T3_NATIVE_CLIENT_VERBOSE: "1" + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.sha }} + + # A dispatch names any commit GitHub has, including a fork's. Only a commit that + # collaborators pushed here builds with the cache login and the shared disk. + - name: Check the commit belongs to this repository + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(git rev-parse HEAD)" + if git ls-remote --heads origin | grep -q "^$sha"; then + exit 0 + fi + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$sha...main" --jq .status || true)" + if [ "$status" = "ahead" ] || [ "$status" = "identical" ]; then + exit 0 + fi + echo "::error::$sha is not the head of a branch in $GITHUB_REPOSITORY and not on main; build it locally." + exit 1 + + # The build runs dependency scripts; it gets no token. checkout keeps the token in a + # file under RUNNER_TEMP that .git/config includes, so both go. `persist-credentials: + # false` fails here: its cleanup runs `git submodule foreach`, which stops at the + # vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + git config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git config --local --unset-all "$key" + done + git config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git config --local --unset-all "$key" + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if git config --local --get-regexp 'extraheader|includeif' || ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::checkout credentials are still present" + exit 1 + fi + + - name: Prepare build directories + run: | + cache="$HOME/.cache/t3-android-dev-client" + { + echo "GRADLE_USER_HOME=$cache/gradle" + echo "CCACHE_DIR=$cache/ccache" + echo "CCACHE_MAXSIZE=5G" + # Gradle installs the NDK fresh each run, so key on the compiler's version, not its mtime. + echo "CCACHE_COMPILERCHECK=%compiler% -v" + # The checkout path is the same on every run, so ccache keys stay stable. + echo "CCACHE_BASEDIR=$GITHUB_WORKSPACE" + # CMake (3.17+) reads these, and AGP passes Gradle's environment through. + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" + } >> "$GITHUB_ENV" + + # Pull requests build cold: their code must not write Gradle or ccache state that + # the runs holding the cache login restore. + - name: Mount build cache + if: github.event_name != 'pull_request' + uses: useblacksmith/stickydisk@v1 + with: + key: ${{ github.repository }}-android-dev-client-${{ matrix.abi }} + path: ~/.cache/t3-android-dev-client + + - name: Configure Gradle and ccache + run: | + mkdir -p "$GRADLE_USER_HOME" "$CCACHE_DIR" + # No daemon: nothing should hold the sticky disk open when it is committed. + printf 'org.gradle.caching=true\norg.gradle.daemon=false\n' > "$GRADLE_USER_HOME/gradle.properties" + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update -qq && sudo apt-get install -y -qq ccache + ccache --zero-stats + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} + install-args: --filter=@t3tools/mobile... --filter=@t3tools/scripts... + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + # Only this step's login can write to the cache; pull requests never get it. + # Only this login can write to the cache; pull requests never get it. + - id: login + name: Log in to the native client cache + if: github.event_name != 'pull_request' + env: + WRITE_TOKEN: ${{ secrets.T3_NATIVE_CLIENT_WRITE_TOKEN }} + run: | + if [ -z "$WRITE_TOKEN" ]; then + echo "::warning::T3_NATIVE_CLIENT_WRITE_TOKEN is not set; nothing can be shared, so nothing is built." + exit 0 + fi + node --input-type=module <<'NODE' + import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; + import { homedir } from "node:os"; + import { join } from "node:path"; + const { endpoint } = JSON.parse(readFileSync(".task-cache.json", "utf8")); + const directory = join(homedir(), ".config/t3-task-cache"); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + writeFileSync( + join(directory, "credentials.json"), + JSON.stringify({ [endpoint]: { name: "ci-native-clients", token: process.env.WRITE_TOKEN } }), + { mode: 0o600 }, + ); + NODE + echo "T3_NATIVE_CLIENT_SHARE=required" >> "$GITHUB_ENV" + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Build and share + if: github.event_name == 'pull_request' || steps.login.outputs.ready == 'true' + shell: bash # pipefail: a failed build fails the step through tee + run: | + node scripts/mobile-native-client.ts ensure android --prepare --abi "$ABI" ${EXPECTED_FINGERPRINT:+--expect-fingerprint "$EXPECTED_FINGERPRINT"} | tee "$RUNNER_TEMP/dev-client.log" + result="$(tail -n 1 "$RUNNER_TEMP/dev-client.log")" + echo "### android dev client: \`$(jq -r '.source' <<<"$result")\` for fingerprint \`$(jq -r '.fingerprint' <<<"$result")\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Cache statistics + if: success() || failure() + continue-on-error: true + run: | + { + echo '```' + ccache --show-stats + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + ios: + name: iOS simulator + if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'workflow_dispatch' || inputs.platform == 'ios') + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 60 + concurrency: + group: mobile-dev-client-ios-${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.fingerprint || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: + NODE_OPTIONS: --max-old-space-size=8192 + EXPECTED_FINGERPRINT: ${{ inputs.fingerprint }} + T3_NATIVE_CLIENT_CACHE: ${{ github.event_name == 'pull_request' && 'off' || '' }} + # Gradle and Xcode output goes to the job log, not a file on the runner. + T3_NATIVE_CLIENT_VERBOSE: "1" + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.sha }} + + # A dispatch names any commit GitHub has, including a fork's. Only a commit that + # collaborators pushed here builds with the cache login and the shared disk. + - name: Check the commit belongs to this repository + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(git rev-parse HEAD)" + if git ls-remote --heads origin | grep -q "^$sha"; then + exit 0 + fi + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$sha...main" --jq .status || true)" + if [ "$status" = "ahead" ] || [ "$status" = "identical" ]; then + exit 0 + fi + echo "::error::$sha is not the head of a branch in $GITHUB_REPOSITORY and not on main; build it locally." + exit 1 + + # The build runs dependency scripts; it gets no token. checkout keeps the token in a + # file under RUNNER_TEMP that .git/config includes, so both go. `persist-credentials: + # false` fails here: its cleanup runs `git submodule foreach`, which stops at the + # vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + git config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git config --local --unset-all "$key" + done + git config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git config --local --unset-all "$key" + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if git config --local --get-regexp 'extraheader|includeif' || ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::checkout credentials are still present" + exit 1 + fi + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} + install-args: --filter=@t3tools/mobile... --filter=@t3tools/scripts... + + # Only this login can write to the cache; pull requests never get it. + - id: login + name: Log in to the native client cache + if: github.event_name != 'pull_request' + env: + WRITE_TOKEN: ${{ secrets.T3_NATIVE_CLIENT_WRITE_TOKEN }} + run: | + if [ -z "$WRITE_TOKEN" ]; then + echo "::warning::T3_NATIVE_CLIENT_WRITE_TOKEN is not set; nothing can be shared, so nothing is built." + exit 0 + fi + node --input-type=module <<'NODE' + import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; + import { homedir } from "node:os"; + import { join } from "node:path"; + const { endpoint } = JSON.parse(readFileSync(".task-cache.json", "utf8")); + const directory = join(homedir(), ".config/t3-task-cache"); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + writeFileSync( + join(directory, "credentials.json"), + JSON.stringify({ [endpoint]: { name: "ci-native-clients", token: process.env.WRITE_TOKEN } }), + { mode: 0o600 }, + ); + NODE + echo "T3_NATIVE_CLIENT_SHARE=required" >> "$GITHUB_ENV" + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Build and share + if: github.event_name == 'pull_request' || steps.login.outputs.ready == 'true' + shell: bash # pipefail: a failed build fails the step through tee + run: | + node scripts/mobile-native-client.ts ensure ios --prepare ${EXPECTED_FINGERPRINT:+--expect-fingerprint "$EXPECTED_FINGERPRINT"} | tee "$RUNNER_TEMP/dev-client.log" + result="$(tail -n 1 "$RUNNER_TEMP/dev-client.log")" + echo "### ios dev client: \`$(jq -r '.source' <<<"$result")\` for fingerprint \`$(jq -r '.fingerprint' <<<"$result")\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml index e7615d46011bd..a8bf5725af70c 100644 --- a/.github/workflows/mobile-eas-preview-fork.yml +++ b/.github/workflows/mobile-eas-preview-fork.yml @@ -1,8 +1,8 @@ name: Mobile EAS Preview (Fork) # EAS previews for fork PRs. This runs the PR's code with an Expo token: the -# app config and plugins run on this runner, and native builds run on EAS -# with preview signing credentials. The label is the maintainer's sign-off +# app config and plugins run on this runner, the iOS client builds on EAS and +# the Android one on Blacksmith (android-build), with preview signing credentials. The label is the maintainer's sign-off # that they reviewed that code, so it covers exactly the commit it was applied # to: this pins that commit and consumes the label before running anything. A # later push deploys nothing until the label is applied again. @@ -29,6 +29,9 @@ jobs: permissions: contents: read pull-requests: write + outputs: + build: ${{ steps.client.outputs.build }} + fingerprint: ${{ steps.client.outputs.fingerprint }} env: APP_VARIANT: preview NODE_OPTIONS: --max-old-space-size=8192 @@ -76,9 +79,43 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - persist-credentials: false # No sparse-checkout: see mobile-eas-preview.yml. + # The comment script comes from main, not from the PR. + - name: Checkout trusted scripts + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@v6 + with: + path: .trusted + sparse-checkout: .github/scripts + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + if: steps.expo-token.outputs.present == 'true' + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + # No caches: pull_request_target saves them under main's scope, where # trusted workflows with other secrets would restore what PR code wrote. - name: Setup Vite+ @@ -139,7 +176,180 @@ jobs: with: profile: preview:dev branch: pr-${{ github.event.pull_request.number }} - platform: all + # Android is below: its client builds on Blacksmith (android-build). + platform: ios environment: preview working-directory: apps/mobile github-token: ${{ secrets.GITHUB_TOKEN }} + + # Android, as in mobile-eas-preview.yml: the same fingerprint command the Expo + # action runs. A client built for a same-repo PR (its tag) or by EAS is reused. + - id: client + name: Find the Android client for this fingerprint + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + hash="$(eas env:exec --non-interactive preview "npx expo-updates fingerprint:generate --platform android" | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + echo "fingerprint=$hash" >> "$GITHUB_OUTPUT" + tag="mobile-android-preview-build/$hash" + link="" + if git fetch --quiet origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null; then + link="$(git tag -l --format='%(contents:subject)' "$tag")" + else + link="$(eas build:list --platform android --build-profile preview:dev --runtime-version "$hash" --status finished --limit 5 --json --non-interactive \ + | sed -n '/^\[/,$p' \ + | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/pingdotgg/projects/t3-code/builds/\(.id)"')" + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + if [ -z "$link" ]; then echo "build=true" >> "$GITHUB_OUTPUT"; fi + + - id: update + name: Publish the Android update + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + PR: ${{ github.event.pull_request.number }} + run: | + group="$(eas update --auto --branch "pr-$PR" --platform android --environment preview --non-interactive --json | sed -n '/^\[/,$p' | jq -er '.[0].group')" + echo "group=$group" >> "$GITHUB_OUTPUT" + + - name: Comment + if: steps.expo-token.outputs.present == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + FINGERPRINT: ${{ steps.client.outputs.fingerprint }} + CLIENT: ${{ steps.client.outputs.link }} + GROUP: ${{ steps.update.outputs.group }} + run: node .trusted/.github/scripts/mobile-preview-comment.cjs + + # The reviewed commit's Android client, built cold: no sticky disk, because PR + # code could leave Gradle or ccache state that trusted builds would restore, and + # no build record tag, so nobody else is served an APK a fork's code built. + android-build: + name: Android preview client + needs: preview + if: needs.preview.outputs.build == 'true' + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 60 + permissions: + contents: read + pull-requests: write + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + FINGERPRINT: ${{ needs.preview.outputs.fingerprint }} + # Phones only: emulators use the development client from the native-client cache. + ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a + steps: + - name: Checkout reviewed commit + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + # The comment script comes from main, not from the PR. + - name: Checkout trusted scripts + uses: actions/checkout@v6 + with: + path: .trusted + sparse-checkout: .github/scripts + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Enable corepack + run: | + npm install --global corepack@latest + corepack enable + pnpm --version + + - name: Install mobile dependencies + run: pnpm install --frozen-lockfile --filter=@t3tools/mobile... + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup EAS + uses: expo/expo-github-action@v8 + with: + eas-version: latest + eas-cache: false + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + eas env:pull preview --non-interactive + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (googleServicesFile) { + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + } + NODE + + - id: build + name: Build and upload + working-directory: apps/mobile + shell: bash + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + eas build --local --platform android --profile preview:dev --non-interactive --output "$RUNNER_TEMP/t3code-preview.apk" + eas upload --platform android --build-path "$RUNNER_TEMP/t3code-preview.apk" --fingerprint "$FINGERPRINT" --non-interactive --json | tee "$RUNNER_TEMP/upload.json" + link="$(sed -n '/^{/,$p' "$RUNNER_TEMP/upload.json" | jq -r '.url // empty')" + if [ -z "$link" ]; then + echo "::error::eas upload returned no install link" + exit 1 + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + + - name: Comment + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + CLIENT: ${{ steps.build.outputs.link }} + run: node .trusted/.github/scripts/mobile-preview-comment.cjs diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 08183d0385811..5989d7a0c0cc8 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -6,7 +6,7 @@ on: jobs: preview: - name: EAS Preview + name: EAS Preview (iOS) # Fork PRs deploy one reviewed commit per label through mobile-eas-preview-fork.yml. if: | github.event.pull_request.head.repo.full_name == github.repository && @@ -107,7 +107,349 @@ jobs: with: profile: preview:dev branch: pr-${{ github.event.pull_request.number }} - platform: all + # Android is the android job below: its client builds on Blacksmith. + platform: ios environment: preview working-directory: apps/mobile github-token: ${{ secrets.GITHUB_TOKEN }} + + # Android publishes its own update and comment, because the Expo action can only + # find a client that EAS built. A fingerprint with no client is built by + # android-build with `eas build --local` on Blacksmith (about 4 minutes warm, + # against about 40 on EAS) and recorded as a mobile-android-preview-build/ + # tag whose message is the install link. Clients EAS built earlier still count. + android: + name: EAS Preview (Android) + if: | + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') && + (github.event.action != 'labeled' || github.event.label.name == '🚀 Mobile Continuous Deployment') + runs-on: blacksmith-8vcpu-ubuntu-2404 + concurrency: + group: mobile-eas-preview-android-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + outputs: + build: ${{ steps.client.outputs.build }} + fingerprint: ${{ steps.client.outputs.fingerprint }} + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + steps: + - id: expo-token + name: Check for EXPO_PREVIEW_TOKEN + run: | + if [ -n "$EXPO_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "EXPO_PREVIEW_TOKEN is not available; skipping the Android preview." + fi + + - name: Checkout + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + if: steps.expo-token.outputs.present == 'true' + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + + - name: Setup Vite+ + if: steps.expo-token.outputs.present == 'true' + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/mobile... + + - name: Expose pnpm + if: steps.expo-token.outputs.present == 'true' + 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: Setup EAS + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + run: | + eas env:pull preview --non-interactive + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (googleServicesFile) { + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + } + NODE + + # The same command the Expo action runs, so this is the runtime version the update gets. + - id: client + name: Find the client for this fingerprint + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + run: | + hash="$(eas env:exec --non-interactive preview "npx expo-updates fingerprint:generate --platform android" | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + echo "fingerprint=$hash" >> "$GITHUB_OUTPUT" + tag="mobile-android-preview-build/$hash" + link="" + if git fetch --quiet origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null; then + link="$(git tag -l --format='%(contents:subject)' "$tag")" + else + # A client EAS built before this job existed; expired ones are gone. + link="$(eas build:list --platform android --build-profile preview:dev --runtime-version "$hash" --status finished --limit 5 --json --non-interactive \ + | sed -n '/^\[/,$p' \ + | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/pingdotgg/projects/t3-code/builds/\(.id)"')" + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + if [ -z "$link" ]; then echo "build=true" >> "$GITHUB_OUTPUT"; fi + echo "android fingerprint \`$hash\`: ${link:-no client yet, building on Blacksmith}" >> "$GITHUB_STEP_SUMMARY" + + - id: update + name: Publish the update + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + run: | + group="$(eas update --auto --branch "pr-$PR" --platform android --environment preview --non-interactive --json | sed -n '/^\[/,$p' | jq -er '.[0].group')" + echo "group=$group" >> "$GITHUB_OUTPUT" + + - name: Comment + if: steps.expo-token.outputs.present == 'true' + env: + FINGERPRINT: ${{ steps.client.outputs.fingerprint }} + CLIENT: ${{ steps.client.outputs.link }} + GROUP: ${{ steps.update.outputs.group }} + run: node .github/scripts/mobile-preview-comment.cjs + + android-build: + name: Android preview client + needs: android + if: needs.android.outputs.build == 'true' + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 60 + concurrency: + # One build per fingerprint; a second PR with the same one finds the tag. + group: mobile-android-preview-build-${{ needs.android.outputs.fingerprint }} + cancel-in-progress: false + permissions: + contents: write # pushes the build record tag + pull-requests: write + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + FINGERPRINT: ${{ needs.android.outputs.fingerprint }} + BUILD_TAG: mobile-android-preview-build/${{ needs.android.outputs.fingerprint }} + # Phones only: emulators use the development client from the native-client cache. + ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - id: record + name: Check build record + run: | + if git fetch --quiet origin "refs/tags/$BUILD_TAG:refs/tags/$BUILD_TAG" 2>/dev/null; then + echo "built=true" >> "$GITHUB_OUTPUT" + echo "link=$(git tag -l --format='%(contents:subject)' "$BUILD_TAG")" >> "$GITHUB_OUTPUT" + fi + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + + - name: Prepare build directories + if: steps.record.outputs.built != 'true' + run: | + cache="$HOME/.cache/t3-android-preview" + { + echo "GRADLE_USER_HOME=$cache/gradle" + echo "CCACHE_DIR=$cache/ccache" + echo "CCACHE_MAXSIZE=5G" + # Gradle installs the NDK fresh each run, so key on the compiler's version, not its mtime. + echo "CCACHE_COMPILERCHECK=%compiler% -v" + echo "pnpm_config_store_dir=$cache/pnpm-store" + # eas build --local copies the project here. A fixed path keeps + # ccache keys stable, and the same disk as the store lets pnpm hardlink. + echo "EAS_LOCAL_BUILD_WORKINGDIR=$cache/eas-build" + echo "CCACHE_BASEDIR=$cache/eas-build" + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" + } >> "$GITHUB_ENV" + + - name: Mount build cache + if: steps.record.outputs.built != 'true' + uses: useblacksmith/stickydisk@v1 + with: + key: ${{ github.repository }}-android-preview-build + path: ~/.cache/t3-android-preview + + - name: Configure Gradle and ccache + if: steps.record.outputs.built != 'true' + run: | + mkdir -p "$GRADLE_USER_HOME" "$CCACHE_DIR" + rm -rf "$EAS_LOCAL_BUILD_WORKINGDIR" + # No daemon: nothing should hold the sticky disk open when it is committed. + printf 'org.gradle.caching=true\norg.gradle.daemon=false\n' > "$GRADLE_USER_HOME/gradle.properties" + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update -qq && sudo apt-get install -y -qq ccache + ccache --zero-stats + + - name: Setup Node + if: steps.record.outputs.built != 'true' + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Enable corepack + if: steps.record.outputs.built != 'true' + run: | + npm install --global corepack@latest + corepack enable + pnpm --version + + - name: Install mobile dependencies + if: steps.record.outputs.built != 'true' + run: pnpm install --frozen-lockfile --prefer-offline --filter=@t3tools/mobile... + + - name: Setup Java + if: steps.record.outputs.built != 'true' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup EAS + if: steps.record.outputs.built != 'true' + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + run: | + eas env:pull preview --non-interactive + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (googleServicesFile) { + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + } + NODE + + # `eas upload` hosts the APK on EAS and returns the page testers install from. + - id: build + name: Build and upload + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + shell: bash + run: | + eas build --local --platform android --profile preview:dev --non-interactive --output "$RUNNER_TEMP/t3code-preview.apk" + eas upload --platform android --build-path "$RUNNER_TEMP/t3code-preview.apk" --fingerprint "$FINGERPRINT" --non-interactive --json | tee "$RUNNER_TEMP/upload.json" + link="$(sed -n '/^{/,$p' "$RUNNER_TEMP/upload.json" | jq -r '.url // empty')" + if [ -z "$link" ]; then + echo "::error::eas upload returned no install link" + exit 1 + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + { + echo "### Android preview client for \`$FINGERPRINT\`: $link" + echo '```' + ccache --show-stats + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # The tag is the record later runs look up; its message is the install link. + - name: Record build + if: steps.record.outputs.built != 'true' + env: + LINK: ${{ steps.build.outputs.link }} + GH_TOKEN: ${{ github.token }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$BUILD_TAG" -m "$LINK" + # Only this step has the token; the checkout's copy was removed before the build. + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$BUILD_TAG" + + - name: Comment + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + CLIENT: ${{ steps.build.outputs.link || steps.record.outputs.link }} + run: node .github/scripts/mobile-preview-comment.cjs diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 6877f5c831a08..f53e2747bc7d7 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -7,6 +7,12 @@ name: Mobile EAS Production # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. # # Every merge reconciles native fingerprints and publishes matching OTAs. +# Android store builds run here with `eas build --local` on Blacksmith (the +# android job). iOS builds run on EAS unless the MOBILE_IOS_LOCAL_BUILDS +# repository variable is `true`, which moves them to the ios job the same way. +# Local builds never appear in `eas build:list`, so each finished local build +# pushes a mobile-android-build// or +# mobile-ios-build/// tag that the reconcile reads. # iOS prereleases use the testflight profile (3.0.0); App Store binaries use # production and the committed app version, built manually. Both receive OTAs # for their own major version. Android continues using production automatically. @@ -65,20 +71,29 @@ on: - 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 ${{ inputs.profile || 'production' }} ${{ github.event_name == 'push' && 'auto' || inputs.mode }} if: github.repository == 'pingdotgg/t3code' runs-on: blacksmith-8vcpu-ubuntu-2404 + # Serialize runs so OTAs publish in merge order. GitHub keeps at most one + # queued job 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 permissions: contents: read + outputs: + android_build: ${{ steps.reconcile.outputs.android_build || steps.android_manual.outputs.android_build }} + android_fingerprint: ${{ steps.reconcile.outputs.android_fingerprint || steps.android_manual.outputs.android_fingerprint }} + android_version: ${{ steps.reconcile.outputs.android_version || steps.android_manual.outputs.android_version }} + android_sha: ${{ steps.reconcile.outputs.android_sha || steps.android_manual.outputs.android_sha }} + ios_build: ${{ steps.reconcile.outputs.ios_build || steps.ios_manual.outputs.ios_build }} + ios_profile: ${{ steps.reconcile.outputs.ios_profile || steps.ios_manual.outputs.ios_profile }} + ios_fingerprint: ${{ steps.reconcile.outputs.ios_fingerprint || steps.ios_manual.outputs.ios_fingerprint }} + ios_version: ${{ steps.reconcile.outputs.ios_version || steps.ios_manual.outputs.ios_version }} + ios_sha: ${{ steps.reconcile.outputs.ios_sha || steps.ios_manual.outputs.ios_sha }} env: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 @@ -240,13 +255,51 @@ jobs: 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' + - name: Build and submit iOS (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'android' && vars.MOBILE_IOS_LOCAL_BUILDS != 'true' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} - run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait + run: eas build --platform ios --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait + + - id: ios_manual + name: Queue iOS build (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'android' && vars.MOBILE_IOS_LOCAL_BUILDS == 'true' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} + run: | + if [ "$MOBILE_BUILD_PROFILE" = "testflight" ]; then + T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')" + export T3CODE_MOBILE_VERSION + fi + version="$(npx expo config --json --type public | jq -r '.version')" + hash="$(eas fingerprint:generate --platform ios --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + { + echo "ios_build=true" + echo "ios_profile=$MOBILE_BUILD_PROFILE" + echo "ios_fingerprint=$hash" + echo "ios_version=$version" + echo "ios_sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + + - id: android_manual + name: Queue Android build (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'ios' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + run: | + version="$(npx expo config --json --type public | jq -r '.version')" + hash="$(eas fingerprint:generate --platform android --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + { + echo "android_build=true" + echo "android_fingerprint=$hash" + echo "android_version=$version" + echo "android_sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" # @expo/fingerprint swallows autolinking errors and returns no Expo module # sources, so native changes stop moving the hash and OTAs reach binaries @@ -300,6 +353,7 @@ jobs: working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + IOS_LOCAL_BUILDS: ${{ vars.MOBILE_IOS_LOCAL_BUILDS == 'true' }} run: | failed=0 message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" @@ -320,6 +374,22 @@ jobs: active="$(jq '[.[] | select(.status == "NEW" or .status == "IN_QUEUE" or .status == "IN_PROGRESS")] | length' <<<"$matches")" errored="$(jq '[.[] | select(.status == "ERRORED")] | length' <<<"$matches")" latest_version="$(eas build:list --platform "$platform" --build-profile "$profile" --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')" + # Local builds (the android and ios jobs) record each build as a tag; + # EAS records still count for builds made before that. + tag_prefix="" + if [ "$platform" = "android" ]; then + tag_prefix="mobile-android-build" + elif [ "$IOS_LOCAL_BUILDS" = "true" ]; then + tag_prefix="mobile-ios-build/$profile" + fi + if [ -n "$tag_prefix" ]; then + if [ -n "$(git tag -l "$tag_prefix/*/$hash")" ]; then + finished=$((finished + 1)) + fi + if [ -n "$(git tag -l "$tag_prefix/$version/*")" ]; then + latest_version="$version" + fi + fi if [ "$finished" -gt 0 ]; then if eas update \ @@ -363,6 +433,27 @@ jobs: fi echo "$platform: $reason — building" + if [ "$platform" = "android" ]; then + { + echo "android_build=true" + echo "android_fingerprint=$hash" + echo "android_version=$version" + echo "android_sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + echo ":building_construction: android: building and submitting $version in the Android store build job ($reason)" >> "$GITHUB_STEP_SUMMARY" + continue + fi + if [ "$platform" = "ios" ] && [ "$IOS_LOCAL_BUILDS" = "true" ]; then + { + echo "ios_build=true" + echo "ios_profile=$profile" + echo "ios_fingerprint=$hash" + echo "ios_version=$version" + echo "ios_sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + echo ":building_construction: ios/$profile: building and submitting $version in the iOS store build job ($reason)" >> "$GITHUB_STEP_SUMMARY" + continue + fi if eas build --platform "$platform" --profile "$profile" --auto-submit --non-interactive --no-wait; then echo ":building_construction: $platform: scheduled production build and submission for $version ($reason)" >> "$GITHUB_STEP_SUMMARY" else @@ -386,3 +477,358 @@ jobs: node scripts/upload-sourcemaps.mts ota "$dir" fi done + + # `eas build --local` on Blacksmith, ARM-only, without release lint (see the + # production profile in eas.json), with Gradle, ccache, and pnpm state on a + # sticky disk. Its own concurrency group keeps later OTAs from waiting on it. + android: + name: Android store build + needs: production + # Skipped when the production job failed: no store build after a failed OTA. + if: needs.production.outputs.android_build == 'true' + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 60 + concurrency: + group: mobile-android-store-build + cancel-in-progress: false + permissions: + contents: write # pushes the build record tag + env: + APP_VARIANT: production + NODE_OPTIONS: --max-old-space-size=8192 + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + BUILD_TAG: mobile-android-build/${{ needs.production.outputs.android_version }}/${{ needs.production.outputs.android_fingerprint }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.production.outputs.android_sha }} + fetch-depth: 0 + + # Dependency scripts and the build run in this job; only Record build gets a token. + # persist-credentials: false would clean up via `git submodule foreach`, which fails on .repos gitlinks. + - name: Drop checkout credentials + run: | + git config --local --unset-all http.https://github.com/.extraheader || true + { git config --local --name-only --get-regexp '^includeif\.' || true; } | while read -r key; do + path=$(git config --local --get "$key") + git config --local --unset-all "$key" + case "$path" in "$RUNNER_TEMP"/*) rm -f "$path" ;; esac + done + if git config --local --get-regexp '^(http\..*\.extraheader|includeif\.)'; then + echo "::error::Checkout credentials are still configured" + exit 1 + fi + + # A queued run can follow a build of the same fingerprint; manual runs always build. + - id: record + name: Check build record + env: + FORCE: ${{ github.event_name == 'workflow_dispatch' }} + run: | + if [ "$FORCE" != "true" ] && [ -n "$(git tag -l "$BUILD_TAG")" ]; then + echo "built=true" >> "$GITHUB_OUTPUT" + echo ":white_check_mark: android: \`$BUILD_TAG\` was built by an earlier run" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Prepare build directories + if: steps.record.outputs.built != 'true' + run: | + cache="$HOME/.cache/t3-android-store" + { + echo "GRADLE_USER_HOME=$cache/gradle" + echo "CCACHE_DIR=$cache/ccache" + echo "CCACHE_MAXSIZE=5G" + # Gradle installs the NDK fresh each run, so key on the compiler's version, not its mtime. + echo "CCACHE_COMPILERCHECK=%compiler% -v" + echo "pnpm_config_store_dir=$cache/pnpm-store" + # eas build --local copies the project here. A fixed path keeps + # ccache keys stable, and the same disk as the store lets pnpm hardlink. + echo "EAS_LOCAL_BUILD_WORKINGDIR=$cache/eas-build" + echo "CCACHE_BASEDIR=$cache/eas-build" + # CMake (3.17+) reads these, and AGP passes Gradle's environment through. + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" + } >> "$GITHUB_ENV" + + - name: Mount build cache + if: steps.record.outputs.built != 'true' + uses: useblacksmith/stickydisk@v1 + with: + key: ${{ github.repository }}-android-store-build + path: ~/.cache/t3-android-store + + - name: Configure Gradle and ccache + if: steps.record.outputs.built != 'true' + run: | + mkdir -p "$GRADLE_USER_HOME" "$CCACHE_DIR" + rm -rf "$EAS_LOCAL_BUILD_WORKINGDIR" + # No daemon: nothing should hold the sticky disk open when it is committed. + printf 'org.gradle.caching=true\norg.gradle.daemon=false\n' > "$GRADLE_USER_HOME/gradle.properties" + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update -qq && sudo apt-get install -y -qq ccache + ccache --zero-stats + + - name: Setup Node + if: steps.record.outputs.built != 'true' + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Enable corepack + if: steps.record.outputs.built != 'true' + run: | + npm install --global corepack@latest + corepack enable + pnpm --version + + # EAS commands load app.config.ts, whose plugins must resolve. Keep install + # scripts: some download native assets that the build's fingerprint check hashes. + - name: Install mobile dependencies + if: steps.record.outputs.built != 'true' + run: pnpm install --frozen-lockfile --prefer-offline --filter=@t3tools/mobile... + + - name: Setup Java + if: steps.record.outputs.built != 'true' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup EAS + if: steps.record.outputs.built != 'true' + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + packager: npm + + - name: Pull production environment variables + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + run: | + eas env:pull production --non-interactive + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (!googleServicesFile) { + throw new Error("T3CODE_ANDROID_GOOGLE_SERVICES_FILE is missing from the production environment"); + } + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + NODE + + - name: Build + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + env: + # EAS secret variables don't reach local builds; the eas-build-on-success + # hook needs these to upload the bundle's source map. + MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }} + run: | + eas build --local --platform android --profile production --non-interactive --output "$RUNNER_TEMP/t3code.aab" + abis="$(unzip -Z1 "$RUNNER_TEMP/t3code.aab" | sed -n 's|^base/lib/\([^/]*\)/.*|\1|p' | sort -u | paste -sd, -)" + { + echo "### Android $BUILD_TAG" + echo "- ABIs: \`$abis\`" + echo '```' + ccache --show-stats + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + if [ "$abis" != "arm64-v8a,armeabi-v7a" ]; then + echo "::error::Expected only ARM native libraries in the AAB, found $abis" + exit 1 + fi + + - name: Submit to Google Play + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + run: eas submit --platform android --profile production --path "$RUNNER_TEMP/t3code.aab" --non-interactive + + # The tag means "delivered", so it follows the submit. If it can't be + # pushed, the next merge rebuilds with a new build number; nothing is lost. + - name: Record build + if: steps.record.outputs.built != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(git rev-parse HEAD)" + # GitHub occasionally fails ref writes server-side ("fatal error in commit_refs"). + for attempt in 1 2 3; do + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$BUILD_TAG" >/dev/null 2>&1; then + echo "$BUILD_TAG exists." + break + fi + if gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$BUILD_TAG" -f sha="$sha" >/dev/null; then + break + fi + if [ "$attempt" = 3 ]; then + echo "::error::Submitted, but could not create $BUILD_TAG; the next merge will rebuild." + exit 1 + fi + sleep $((attempt * 15)) + done + echo ":rocket: android: submitted and recorded \`$BUILD_TAG\`" >> "$GITHUB_STEP_SUMMARY" + + # Off unless the MOBILE_IOS_LOCAL_BUILDS repository variable is `true`; the + # production job only sets ios_build then. Store binaries need the final + # Xcode 27.1; MOBILE_IOS_ALLOW_XCODE_RC=true accepts the release candidate. + # No sticky disk: it mounts ext4 and only works on Linux runners. + ios: + name: iOS store build + needs: production + # Skipped when the production job failed: no store build after a failed OTA. + if: needs.production.outputs.ios_build == 'true' + runs-on: blacksmith-12vcpu-macos-27 + timeout-minutes: 60 + concurrency: + group: mobile-ios-store-build + cancel-in-progress: false + permissions: + contents: write # pushes the build record tag + env: + APP_VARIANT: production + NODE_OPTIONS: --max-old-space-size=8192 + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + PROFILE: ${{ needs.production.outputs.ios_profile }} + BUILD_TAG: mobile-ios-build/${{ needs.production.outputs.ios_profile }}/${{ needs.production.outputs.ios_version }}/${{ needs.production.outputs.ios_fingerprint }} + ALLOW_XCODE_RC: ${{ vars.MOBILE_IOS_ALLOW_XCODE_RC == 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.production.outputs.ios_sha }} + fetch-depth: 0 + + # Dependency scripts and the build run in this job; only Record build gets a token. + # persist-credentials: false would clean up via `git submodule foreach`, which fails on .repos gitlinks. + - name: Drop checkout credentials + run: | + git config --local --unset-all http.https://github.com/.extraheader || true + { git config --local --name-only --get-regexp '^includeif\.' || true; } | while read -r key; do + path=$(git config --local --get "$key") + git config --local --unset-all "$key" + case "$path" in "$RUNNER_TEMP"/*) rm -f "$path" ;; esac + done + if git config --local --get-regexp '^(http\..*\.extraheader|includeif\.)'; then + echo "::error::Checkout credentials are still configured" + exit 1 + fi + + # A queued run can follow a build of the same fingerprint; manual runs always build. + - id: record + name: Check build record + env: + FORCE: ${{ github.event_name == 'workflow_dispatch' }} + run: | + if [ "$FORCE" != "true" ] && [ -n "$(git tag -l "$BUILD_TAG")" ]; then + echo "built=true" >> "$GITHUB_OUTPUT" + echo ":white_check_mark: ios: \`$BUILD_TAG\` was built by an earlier run" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Select Xcode 27.1 + if: steps.record.outputs.built != 'true' + run: | + xcode=/Applications/Xcode_27.1.app + if [ ! -d "$xcode" ] && [ "$ALLOW_XCODE_RC" = "true" ]; then + xcode=/Applications/Xcode_27.1_Release_Candidate.app + fi + if [ ! -d "$xcode" ]; then + ls -d /Applications/Xcode*.app + echo "::error::Xcode 27.1 (final) is not on this runner. Set the MOBILE_IOS_ALLOW_XCODE_RC repository variable to true to build store binaries with the release candidate." + exit 1 + fi + sudo xcode-select -s "$xcode/Contents/Developer" + xcodebuild -version + echo "- Xcode: \`$(basename "$xcode")\`" >> "$GITHUB_STEP_SUMMARY" + + # EAS's credentials step fails to import the distribution certificate without it. + - name: Install Apple WWDR G3 intermediate + if: steps.record.outputs.built != 'true' + run: | + curl -fsSL -o "$RUNNER_TEMP/AppleWWDRCAG3.cer" https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer + sudo security add-certificates -k /Library/Keychains/System.keychain "$RUNNER_TEMP/AppleWWDRCAG3.cer" || true + security find-certificate -c "Apple Worldwide Developer Relations" /Library/Keychains/System.keychain | grep -m1 labl + + - name: Setup Node + if: steps.record.outputs.built != 'true' + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Enable corepack + if: steps.record.outputs.built != 'true' + run: | + npm install --global corepack@latest + corepack enable + pnpm --version + + # EAS commands load app.config.ts, whose plugins must resolve. Keep install + # scripts: some download native assets that the build's fingerprint check hashes. + - name: Install mobile dependencies + if: steps.record.outputs.built != 'true' + run: pnpm install --frozen-lockfile --prefer-offline --filter=@t3tools/mobile... + + - name: Setup EAS + if: steps.record.outputs.built != 'true' + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + packager: npm + + - name: Build tools + if: steps.record.outputs.built != 'true' + run: | + command -v pod || sudo gem install cocoapods --no-document + command -v fastlane || brew install fastlane + pod --version + fastlane --version | tail -1 + + - name: Pull production environment variables + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + run: eas env:pull production --non-interactive + + - name: Build + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + env: + # EAS secret variables don't reach local builds; the eas-build-on-success + # hook needs these to upload the bundle's source map. + MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }} + run: | + # A real path: /var -> /private/var symlinks break the expo-widgets bundle step. + export EAS_LOCAL_BUILD_WORKINGDIR="$RUNNER_TEMP/eas-build" + eas build --local --platform ios --profile "$PROFILE" --non-interactive --output "$RUNNER_TEMP/t3code.ipa" + + - name: Submit to App Store Connect + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + run: eas submit --platform ios --profile "$PROFILE" --path "$RUNNER_TEMP/t3code.ipa" --non-interactive + + # The tag means "delivered", so it follows the submit. If it can't be + # pushed, the next merge rebuilds with a new build number; nothing is lost. + - name: Record build + if: steps.record.outputs.built != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(git rev-parse HEAD)" + # GitHub occasionally fails ref writes server-side ("fatal error in commit_refs"). + for attempt in 1 2 3; do + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$BUILD_TAG" >/dev/null 2>&1; then + echo "$BUILD_TAG exists." + break + fi + if gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$BUILD_TAG" -f sha="$sha" >/dev/null; then + break + fi + if [ "$attempt" = 3 ]; then + echo "::error::Submitted, but could not create $BUILD_TAG; the next merge will rebuild." + exit 1 + fi + sleep $((attempt * 15)) + done + echo ":rocket: ios: submitted and recorded \`$BUILD_TAG\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 96d83231613d9..b227a2b8b12fe 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -210,6 +210,20 @@ jobs: native/hyprland-snap-shot/target/${{ inputs.rust_target }}/release/t3-hyprland-snap-shot key: linux-capture-helpers-${{ inputs.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} + # Every Linux build makes electron-builder fetch the AppImage runtime, FPM + # for the deb, and the 7-Zip that unpacks FPM from GitHub releases. It + # checks an archive found here against the SHA-256 pinned in those toolset + # files and downloads again on a mismatch, so only raw archives are cached. + - name: Cache Linux packaging tool archives + if: inputs.platform == 'linux' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: | + ~/.cache/electron-builder/appimage@*/appimage-tools-runtime-*.tar.gz + ~/.cache/electron-builder/fpm@*/fpm-*.7z + ~/.cache/electron-builder/7zip@*/7zip-linux-*.tar.gz + key: electron-builder-linux-tools-${{ inputs.arch }}-${{ hashFiles('node_modules/.pnpm/app-builder-lib@*/node_modules/app-builder-lib/out/toolsets/linux.js', 'node_modules/.pnpm/app-builder-lib@*/node_modules/app-builder-lib/out/toolsets/7zip.js') }} + - name: Ensure Rosetta for macOS x64 bytecode generation if: inputs.platform == 'mac' && inputs.arch == 'x64' shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9df801c108b7f..c56c76be7e0e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,8 @@ jobs: env: DISPATCH_CHANNEL: ${{ inputs.channel }} with: + # Every call here is a read; retry GitHub's transient 5xx and dropped connections. + retries: 3 script: | const { assertCommitOnDefaultBranch, @@ -1239,13 +1241,16 @@ jobs: T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }} VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: + # Only what setup-ci-deps reads to install vp and Node. The alias call + # names its deployment and scope, so it reads nothing else from the repo. - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | - /* - !/.repos/ + /.github/actions/setup-ci-deps/ + /package.json + /pnpm-workspace.yaml sparse-checkout-cone-mode: false - name: Setup Vite+ diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index 9ad611688fc91..87a153f7b030d 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -1,8 +1,33 @@ -// Windows installs need Linux x64 glibc binaries because WSL shares node_modules. -// Keep other platforms native; release scripts set their own architectures. +const fs = require("node:fs"); +const path = require("node:path"); + +// Our forks ship as tarballs in apps/mobile/deps. A pnpm patch on one is a second source of +// truth that the next tarball silently drops, so changes go to the fork (apps/mobile/deps/README.md). +function forkTarballPatches(patchedDependencies) { + const manifestPath = path.join(__dirname, "apps/mobile/package.json"); + // Release staging installs a partial workspace without the mobile app. + if (!patchedDependencies || !fs.existsSync(manifestPath)) return []; + const { dependencies = {} } = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const forks = Object.keys(dependencies).filter((name) => + String(dependencies[name]).startsWith("file:deps/"), + ); + return Object.keys(patchedDependencies).filter((key) => + forks.some((name) => key === name || key.startsWith(`${name}@`)), + ); +} + module.exports = { hooks: { updateConfig(config) { + const forkPatches = forkTarballPatches(config.patchedDependencies); + if (forkPatches.length > 0) { + throw new Error( + `pnpm-workspace.yaml patches ${forkPatches.join(", ")}, which ships as a fork tarball in ` + + "apps/mobile/deps. Change the fork and replace the tarball instead (apps/mobile/deps/README.md).", + ); + } + // Windows installs need Linux x64 glibc binaries because WSL shares node_modules. + // Keep other platforms native; release scripts set their own architectures. // oxlint-disable-next-line t3code/no-global-process-runtime -- pnpm loads this file before any Effect runtime exists. if (process.platform !== "win32") return config; return { diff --git a/AGENTS.md b/AGENTS.md index b919730ebe749..2effa629d25e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,13 +18,13 @@ Lots of apps have gotten bogged down with bad tech decisions and "slop". We have ### 3. Remote ready -The architecture of T3 Code's websocket layer (npx t3) enables a lot of awesome remote features. These have become core to the product. Whether users are connecting directly over their local network, using Tailscale, or leaning in fully with T3 Connect (our tunnel solution, also in this repo), we need to make sure new features are properly supported. +The architecture of T3 Code's websocket layer (`t3`) enables a lot of awesome remote features. These have become core to the product. Whether users are connecting directly over their local network, using Tailscale, or leaning in fully with T3 Connect (our tunnel solution, also in this repo), we need to make sure new features are properly supported. ### 4. Multi-surface T3 Code has 3 key app surfaces: **web**, **desktop**, and **mobile**. -**Web** is kind of two surfaces, as we have the public facing "app.t3.codes" as well as locally hosting the web app through the `npx t3` command. Both need to be supported by all new features where reasonable. +**Web** is kind of two surfaces, as we have the public facing "app.t3.codes" as well as locally hosting the web app through the `t3` command. Both need to be supported by all new features where reasonable. **Desktop** is the main surface most users install first. It's a full Electron app that bundles the server runner as well. The desktop app can also be used as the host server, allowing remote connections from app.t3.codes or the mobile app. diff --git a/README.md b/README.md index 1f63f3609ae0d..4abbd5bd932cf 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ irm https://t3.codes/install.ps1 | iex Then run `t3` to start the server and open the local web app. `t3 service install` keeps it running in the background, `t3 update` moves to a newer release, and `t3 --help` has the full reference. -To try it once without installing, run `npx t3@latest` instead. +To try it once without installing, run `npx t3@latest`. It offers to install the `t3` command for you. ### Desktop app diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3d9e3239840cf..4a4884ba73e29 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.7", + "version": "0.1.8", "private": true, "type": "module", "main": "dist-electron/boot.cjs", @@ -12,7 +12,7 @@ "dependencies": { "@clerk/electron": "catalog:", "@clerk/electron-passkeys": "catalog:", - "@crowecawcaw/xa11y": "0.13.0", + "@crowecawcaw/xa11y": "0.15.4", "@effect/platform-node": "catalog:", "@napi-rs/keyring": "^1.3.0", "@t3tools/client-runtime": "workspace:*", diff --git a/apps/desktop/scripts/verify-preload-bundle.mjs b/apps/desktop/scripts/verify-preload-bundle.mjs index f6976220d07fe..cd45e5f1360f6 100644 --- a/apps/desktop/scripts/verify-preload-bundle.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.mjs @@ -103,11 +103,17 @@ const executeBundle = (source, sandboxModules) => { return sandboxModules.get(moduleName); }; + // A real preload loads with the page's DOM; no page events fire while the bundle runs here. + const document = Object.assign(new EventTarget(), { fullscreenElement: null }); + const window = Object.assign(new EventTarget(), { document }); + NodeVM.runInNewContext( source, { + document, process: sandboxProcess, require: requireSandboxModule, + window, }, { filename: "desktop-preload.cjs", diff --git a/apps/desktop/scripts/verify-preload-bundle.test.mjs b/apps/desktop/scripts/verify-preload-bundle.test.mjs index 8af6ae00c3f92..b80fe934f5d4d 100644 --- a/apps/desktop/scripts/verify-preload-bundle.test.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.test.mjs @@ -80,6 +80,18 @@ describe("desktop preload bundle verifier", () => { ); }); + it("runs a preload that reads the page's DOM while loading", () => { + assert.doesNotThrow(() => + verifyPreloadBundle(` + ${validPreload} + const report = () => document.fullscreenElement; + report(); + document.addEventListener("fullscreenchange", report); + window.addEventListener("resize", report); + `), + ); + }); + it("ignores require-like text in strings and comments", () => { assert.doesNotThrow(() => verifyPreloadBundle(` diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 8b5f9f712dc10..6cb00baf897d1 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -117,6 +117,7 @@ it.effect.each([false, true])( Effect.provideService(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(true), awaitDiscovery: Effect.succeed(true), + awaitUserShellDiscovery: Effect.succeed(true), updates: Stream.empty, }), Effect.provideService(DesktopAppSettings.DesktopAppSettings, { diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 3c5f499a1cb3c..e3b6c49e3ab8d 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -15,6 +15,7 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppActivation from "./DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopCliCommand from "./DesktopCliCommand.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as PreviewPasskeys from "../preview/Passkeys.ts"; @@ -192,6 +193,18 @@ const bootstrap = Effect.gen(function* () { ); yield* snapShot.initialize; + // Only the user's own login shell PATH shows every t3 a new terminal runs; a fresh install has + // no cached one. Without it, the command is left to Settings. + const installCli = (yield* DesktopCliCommand.DesktopCliCommand).installOnFirstLaunch; + yield* Effect.forkScoped( + (yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitUserShellDiscovery.pipe( + Effect.flatMap((found) => + found || environment.platform === "win32" + ? installCli + : logBootstrapInfo("skipped installing the t3 command without the shell PATH"), + ), + ), + ); if (!settings.localEnvironmentEnabled) { yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index ebb32bda43ced..2dc59c58899ee 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -24,6 +24,7 @@ const environmentFor = ( homeDirectory: input.home, baseDir: input.baseDir, stateDir: path.join(input.baseDir, "userdata"), + desktopSettingsPath: path.join(input.baseDir, "userdata", "desktop-settings.json"), serverRoot: "/opt/T3 Code/resources/app.asar", appImagePath: Option.none(), } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -48,21 +49,22 @@ const commandIn = ( DesktopEnvironment.DesktopEnvironment, environmentFor(path, { ...input, baseDir }), ), - Effect.provideService(HostProcess.Environment, input.env ?? { PATH: "" }), + Effect.provideService(HostProcess.Environment, input.env ?? { PATH: "", SHELL: "/bin/zsh" }), ); return yield* spawner ? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) : make; }); -const fakePowerShell = (initial: string) => { +/** `inherited` stands in for the app's own environment, which `extendEnv` passes on. */ +const fakePowerShell = (initial: string, inherited: Record = {}) => { const registry = { path: initial, failReads: false, writes: 0 }; const spawner = ChildProcessSpawner.make((command) => Effect.sync(() => { const { options } = command as unknown as { - readonly options: { readonly env?: Record }; + readonly options: { readonly env?: Record; readonly extendEnv?: boolean }; }; - const env = options.env ?? {}; + const env = { ...(options.extendEnv ? inherited : {}), ...options.env }; let exitCode = 0; let stdout = ""; if (env.T3_SET === "1") { @@ -94,10 +96,11 @@ const fakePowerShell = (initial: string) => { it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("links the launcher onto PATH and removes only that link", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); - const command = yield* commandIn({ home }); + const command = yield* commandIn({ home, env }); const link = path.join(home, ".local", "bin", "t3"); expect(yield* command.state).toEqual({ @@ -106,24 +109,66 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { onPath: false, launcherPath: null, }); + yield* command.installOnFirstLaunch; + expect((yield* command.state).installedPath).toBe(link); const installed = yield* command.install; expect(installed.installedPath).toBe(link); expect(installed.launcherPath).toBe(path.join(home, ".t3", "bin", "t3")); + expect(installed.pathSetup).toEqual({ file: "~/.zshrc", added: false }); expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); expect((yield* command.install).installedPath).toBe(link); + // Add to PATH appends one line, once, and keeps a final line with no newline intact. + const zshrc = path.join(home, ".zshrc"); + yield* fs.writeFileString(zshrc, "# mine"); + // A folder already on PATH needs nothing. + env.PATH = path.join(home, ".local", "bin"); + yield* command.addToPath; + expect(yield* fs.readFileString(zshrc)).toBe("# mine"); + env.PATH = ""; + yield* Effect.all([command.addToPath, command.addToPath], { concurrency: "unbounded" }); + expect((yield* command.addToPath).pathSetup).toEqual({ file: "~/.zshrc", added: true }); + expect(yield* fs.readFileString(zshrc)).toBe( + '# mine\n# Added by T3 Code\nexport PATH="$PATH:$HOME/.local/bin"\n', + ); + expect((yield* command.uninstall).installedPath).toBeNull(); expect(yield* fs.exists(link)).toBe(false); expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true); + // Removed stays removed: first launch never installs again. + yield* command.installOnFirstLaunch; + expect(yield* fs.exists(link)).toBe(false); + + // Also when first launch was put off (no shell PATH yet) and the user removed it meanwhile. + const laterHome = yield* fs.makeTempDirectoryScoped(); + const later = yield* commandIn({ home: laterHome, env }); + yield* later.install; + yield* later.uninstall; + yield* later.installOnFirstLaunch; + expect(yield* fs.exists(path.join(laterHome, ".local", "bin", "t3"))).toBe(false); + + // An app that ran before left its settings or launcher. Its user may have removed t3 or + // chosen another, so first launch leaves it to Settings, where Install still works. + for (const left of ["userdata/desktop-settings.json", "bin/t3"]) { + const upgradedHome = yield* fs.makeTempDirectoryScoped(); + const leftFile = path.join(upgradedHome, ".t3", left); + yield* fs.makeDirectory(path.dirname(leftFile), { recursive: true }); + yield* fs.writeFileString(leftFile, `#!/bin/sh\n# ${DesktopCliShim.MARKER}\n`); + const upgraded = yield* commandIn({ home: upgradedHome, env }); + yield* upgraded.installOnFirstLaunch; + const upgradedLink = path.join(upgradedHome, ".local", "bin", "t3"); + expect(yield* fs.exists(upgradedLink)).toBe(false); + expect((yield* upgraded.install).installedPath).toBe(upgradedLink); + } }).pipe(Effect.scoped), ); it.effect("never replaces or removes a t3 it did not create", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); - const env: NodeJS.ProcessEnv = { PATH: "" }; const command = yield* commandIn({ home, env }); const theirs = path.join(home, ".local", "bin", "t3"); yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); @@ -132,11 +177,24 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3")); const error = yield* Effect.flip(command.install); - expect(error.message).toContain("Another t3 command is already installed"); + expect(error.message).toContain("Another t3 command is already in"); yield* command.uninstall; expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); + // A fresh install beside someone else's t3 (even off PATH or a dangling link) adds no second + // t3 in ~/bin. + for (const dangling of [false, true]) { + const besideHome = yield* fs.makeTempDirectoryScoped(); + const other = path.join(besideHome, ".local", "bin", "t3"); + yield* fs.makeDirectory(path.dirname(other), { recursive: true }); + yield* dangling + ? fs.symlink(path.join(besideHome, "gone"), other) + : fs.writeFileString(other, "npm's t3\n"); + yield* (yield* commandIn({ home: besideHome })).installOnFirstLaunch; + expect(yield* fs.exists(path.join(besideHome, "bin", "t3"))).toBe(false); + } + const launcher = path.join(home, ".t3", "bin", "t3"); yield* fs.writeFileString(launcher, "npm's t3\n"); env.PATH = path.dirname(launcher); @@ -149,40 +207,102 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("finds and removes a link left by a previous T3 home", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); - const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") }); + const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3"), env }); const link = (yield* before.install).installedPath; - const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }); - expect((yield* after.state).installedPath).toBe(link); + const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3"), env }); + // A first launch leaves another home's command alone; only Install takes it over. + yield* after.installOnFirstLaunch; + expect(yield* fs.readLink(link!)).toBe(path.join(home, "old-t3", "bin", "t3")); + // On PATH, it runs the other home's launcher. Settings offers Install (not Remove, and not + // "another t3 runs first"), which updates that link in place. + env.PATH = path.dirname(link!); + expect(yield* after.state).toMatchObject({ installedPath: null, onPath: false }); + expect((yield* after.state).shadowedBy).toBeUndefined(); + env.PATH = ""; + // Off PATH, Add to PATH would expose the other home's command, not this one. + expect((yield* after.state).pathSetup).toBeUndefined(); + yield* Effect.flip(after.addToPath); + expect(yield* fs.exists(path.join(home, ".zshrc"))).toBe(false); + // Behind another t3 the install fails, and the old link still works. + const shadow = path.join(home, "shadow"); + yield* fs.makeDirectory(shadow); + yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + env.PATH = shadow; + yield* Effect.flip(after.install); + expect(yield* fs.readLink(link!)).toBe(path.join(home, "old-t3", "bin", "t3")); + // Install takes it over, also with its folder first on PATH. + env.PATH = path.dirname(link!); expect((yield* after.install).installedPath).toBe(link); expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3")); + env.PATH = ""; yield* after.uninstall; expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); expect(yield* fs.exists(path.join(home, "bin", "t3"))).toBe(false); + + // A folder already on PATH wins, even written with a trailing slash. + env.PATH = `${path.join(home, "bin")}/`; + expect((yield* after.install).installedPath).toBe(path.join(home, "bin", "t3")); + + // An old link of ours is updated where it is, not moved to a folder that may be off PATH. + yield* fs.writeFileString(path.join(home, "bin", "t3.staging"), "mine"); + yield* fs.remove(path.join(home, "bin", "t3")); + yield* fs.symlink(path.join(home, "old-t3", "bin", "t3"), path.join(home, "bin", "t3")); + env.PATH = ""; + expect((yield* after.install).installedPath).toBe(path.join(home, "bin", "t3")); + expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe( + path.join(home, "new-t3", "bin", "t3"), + ); + expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); + expect(yield* fs.readFileString(path.join(home, "bin", "t3.staging"))).toBe("mine"); }).pipe(Effect.scoped), ); it.effect("refuses to install behind another t3 that runs first", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); const shadow = path.join(home, "shadow"); yield* fs.makeDirectory(shadow); yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); - const env = { PATH: [shadow, path.join(home, ".local", "bin")].join(":") }; + env.PATH = [shadow, `${path.join(home, ".local", "bin")}/`].join(":"); const command = yield* commandIn({ home, env }); const theirs = path.join(shadow, "t3"); expect((yield* command.state).shadowedBy).toBe(theirs); const error = yield* Effect.flip(command.install); expect(error.message).toContain(theirs); + yield* command.installOnFirstLaunch; expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); + expect(yield* fs.readFileString(theirs)).toBe("#!/bin/sh\n"); + + // Its folder is already on PATH behind theirs; adding it again would change which t3 runs. + yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); + yield* fs.symlink( + path.join(home, ".t3", "bin", "t3"), + path.join(home, ".local", "bin", "t3"), + ); + const behind = yield* command.state; + expect(behind.installedPath).toBe(path.join(home, ".local", "bin", "t3")); + expect(behind.pathSetup).toBeUndefined(); + // Off PATH behind theirs, adding the folder would still put ours first. + env.PATH = shadow; + expect((yield* command.state).pathSetup).toBeUndefined(); + yield* Effect.flip(command.addToPath); + expect(yield* fs.exists(path.join(home, ".zshrc"))).toBe(false); + env.PATH = [shadow, `${path.join(home, ".local", "bin")}/`].join(":"); + yield* fs.remove(path.join(home, ".local", "bin", "t3")); + // A first-launch install that failed is not retried behind the user's back. yield* fs.remove(theirs); + yield* command.installOnFirstLaunch; + expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); const installed = yield* command.install; expect(installed).toMatchObject({ onPath: true, @@ -196,6 +316,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { "skips a t3 directory or a file this user cannot execute", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); @@ -209,7 +330,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* fs.chmod(path.join(plain, "t3"), process.getuid?.() === 0 ? 0o600 : 0o601); yield* fs.makeDirectory(shadow); yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); - const env = { PATH: [folder, plain, shadow, path.join(home, ".local", "bin")].join(":") }; + env.PATH = [folder, plain, shadow, path.join(home, ".local", "bin")].join(":"); const command = yield* commandIn({ home, env }); expect((yield* command.state).shadowedBy).toBe(path.join(shadow, "t3")); @@ -227,11 +348,12 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("does not treat its own launcher as another t3 when its folder is first on PATH", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); const launcher = path.join(home, ".t3", "bin", "t3"); - const env = { PATH: [path.dirname(launcher), path.join(home, ".local", "bin")].join(":") }; + env.PATH = [path.dirname(launcher), path.join(home, ".local", "bin")].join(":"); const command = yield* commandIn({ home, env }); const installed = yield* command.install; @@ -274,6 +396,21 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(registry.path).toBe(`${userPath};${launcherDir}`); yield* command.uninstall; expect(registry.path).toBe(userPath); + + // A `;` in the folder would split it into two PATH entries, so nothing is written. + const split = fakePowerShell(userPath); + const semicolon = yield* commandIn( + { home, baseDir: `${home}\\T3;Home`, platform: "win32" }, + split.spawner, + ); + expect((yield* Effect.flip(semicolon.install)).message).toContain('has a ";"'); + expect(split.registry).toMatchObject({ path: userPath, writes: 0 }); + + // An inherited T3_SET=1 never turns a read into a write of the whole PATH. + const inherited = fakePowerShell(userPath, { T3_SET: "1", T3_PATH: "C:\\other" }); + const reader = yield* commandIn({ home, platform: "win32" }, inherited.spawner); + yield* reader.install; + expect(inherited.registry).toMatchObject({ path: `${userPath};${launcherDir}`, writes: 1 }); }).pipe(Effect.scoped), ); @@ -294,6 +431,16 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect((yield* command.state).installedPath).toBeNull(); yield* command.uninstall; expect(registry.path).toBe(userPath); + + // A fresh install beside a standalone t3.cmd (from `t3 install`), even off PATH, leaves + // PATH alone. + const besideHome = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(path.join(besideHome, ".local", "bin"), { recursive: true }); + yield* fs.writeFileString(path.join(besideHome, ".local", "bin", "t3.cmd"), "@echo off\r\n"); + const beside = fakePowerShell("C:\\Tools"); + yield* (yield* commandIn({ home: besideHome, platform: "win32" }, beside.spawner)) + .installOnFirstLaunch; + expect(beside.registry).toMatchObject({ path: "C:\\Tools", writes: 0 }); }).pipe(Effect.scoped), ); }); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index de48328850794..b5306a6bb5a82 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -8,23 +8,34 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Random from "effect/Random"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { pathSetup, readUserLoginShell } from "@t3tools/shared/shell"; + import * as DesktopCliShim from "./DesktopCliShim.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +const { logInfo, logWarning } = makeComponentLogger("desktop-cli-command"); export class DesktopCliCommandError extends Schema.TaggedError()( "DesktopCliCommandError", { message: Schema.String }, ) {} -const unixCandidates = (home: string, platform: NodeJS.Platform) => +const userCandidates = (home: string) => [`${home}/.local/bin`, `${home}/bin`]; + +// Older builds also linked into these macOS folders. Such a link is found, updated in place, and +// removed, but no new link goes there. +const knownLocations = (home: string, platform: NodeJS.Platform) => platform === "darwin" - ? ["/opt/homebrew/bin", "/usr/local/bin", `${home}/.local/bin`, `${home}/bin`] - : [`${home}/.local/bin`, `${home}/bin`]; + ? [...userCandidates(home), "/opt/homebrew/bin", "/usr/local/bin"] + : userCandidates(home); const pathEntries = (value: string | undefined, separator: string) => (value ?? "").split(separator).filter((entry) => entry.length > 0); @@ -59,6 +70,10 @@ export class DesktopCliCommand extends Context.Service< readonly state: Effect.Effect; readonly install: Effect.Effect; readonly uninstall: Effect.Effect; + /** Adds the installed command's folder to PATH in the user's shell startup file. */ + readonly addToPath: Effect.Effect; + /** Installs the command on a fresh install's first launch; removing it in Settings sticks. */ + readonly installOnFirstLaunch: Effect.Effect; } >()("@t3tools/desktop/app/DesktopCliCommand") {} @@ -74,9 +89,14 @@ export const make = Effect.gen(function* () { const launcher = DesktopCliShim.launcherPath(environment); const binDirectory = path.dirname(launcher); const ownedPathMarker = path.join(environment.stateDir, "cli-command-path-entry"); + const firstLaunchMarker = path.join(environment.stateDir, "cli-command-first-launch"); const fail = (message: string) => new DesktopCliCommandError({ message }); const exists = (target: string) => fs.exists(target).pipe(Effect.orElseSucceed(() => false)); + // Read before this launch writes either one: an app that ran before left its launcher or + // settings. Its user may have removed t3 or chosen another, so only Settings installs there. + const freshInstall = + !(yield* exists(launcher)) && !(yield* exists(environment.desktopSettingsPath)); const writableDirectory = (directory: string) => fs.access(directory, { writable: true }).pipe( Effect.as(true), @@ -97,8 +117,8 @@ export const make = Effect.gen(function* () { // PATH can reach the regular launcher directly; removal still requires isOurLink. const runsOurLauncher = Effect.fn("DesktopCliCommand.runsOurLauncher")( + // Another T3 home's link is marked too, so compare where it leads, not the marker alone. function* (candidate: string) { - if (yield* isOurLink(candidate)) return true; const [resolved, ours] = yield* Effect.all([fs.realPath(candidate), fs.realPath(launcher)]); if (resolved !== ours) return false; const info = yield* fs.stat(candidate); @@ -127,7 +147,8 @@ export const make = Effect.gen(function* () { return stdout; }), ); - const readUserPath = powershell({}).pipe( + // Explicit, so an inherited T3_SET=1 can never turn a read into a PATH write. + const readUserPath = powershell({ T3_SET: "0" }).pipe( Effect.map((encoded) => Buffer.from(encoded.trim(), "base64").toString("utf8")), Effect.mapError(() => fail("Could not read your PATH, so it was left unchanged.")), ); @@ -164,6 +185,13 @@ export const make = Effect.gen(function* () { return first; }); + /** A link to this home's launcher; another home's link is marked too. */ + const leadsHere = (link: string) => + fs.readLink(link).pipe( + Effect.map((target) => target === launcher), + Effect.orElseSucceed(() => false), + ); + const installedAt = Effect.gen(function* () { if (windows) { if (!(yield* exists(ownedPathMarker))) return Option.none(); @@ -172,31 +200,77 @@ export const make = Effect.gen(function* () { ? Option.some(launcher) : Option.none(); } - for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { + for (const directory of knownLocations(environment.homeDirectory, environment.platform)) { const link = path.join(directory, "t3"); if (yield* isOurLink(link)) return Option.some(link); } return Option.none(); }); + const startupLine = (installedPath: string) => { + const setup = pathSetup({ + directory: path.dirname(installedPath), + home: environment.homeDirectory, + // A Finder or desktop launch may not set SHELL. + shell: env.SHELL || readUserLoginShell(), + platform: environment.platform, + configHome: env.XDG_CONFIG_HOME, + zdotdir: env.ZDOTDIR, + }); + if (setup === undefined) return undefined; + const file = setup.file.startsWith("~/") + ? path.join(environment.homeDirectory, setup.file.slice(2)) + : setup.file; + return { ...setup, path: file }; + }; + const hasLine = (setup: { readonly path: string; readonly line: string }) => + fs.readFileString(setup.path).pipe( + Effect.map((content) => content.split("\n").some((line) => line.trim() === setup.line)), + Effect.orElseSucceed(() => false), + ); + + const folderOnPath = (link: string) => + Effect.sync(() => + pathEntries(env.PATH, ":").some((entry) => entry.replace(/\/+$/, "") === path.dirname(link)), + ); + const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { if (!environment.isPackaged) { return { supported: false, installedPath: null, onPath: false, launcherPath: null } as const; } const launcherPath = (yield* exists(launcher)) ? launcher : null; - const installed = yield* installedAt; - const shadowedBy = yield* foreignFirstOnPath; + const found = yield* installedAt; + // Our link into another T3 home is not this app's command; Install updates it in place. + const installed = + windows || (Option.isSome(found) && (yield* leadsHere(found.value))) + ? found + : Option.none(); + const shadowedBy = (yield* foreignFirstOnPath).pipe( + Option.filter((first) => first !== Option.getOrUndefined(found)), + ); const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; const first = yield* firstOnPath; const onPath = windows ? Option.isSome(installed) : Option.isSome(first) && (yield* runsOurLauncher(first.value)); + // Only a folder missing from PATH, with no other t3 first: with one, the added folder (last + // on PATH) would still not run this t3. + const setup = + onPath || + Option.isSome(shadowedBy) || + Option.isNone(installed) || + (yield* folderOnPath(installed.value)) + ? undefined + : startupLine(installed.value); return { supported: true, installedPath: Option.getOrNull(installed), onPath, launcherPath, ...shadow, + ...(setup === undefined + ? {} + : { pathSetup: { file: setup.file, added: yield* hasLine(setup) } }), }; }).pipe( Effect.orElseSucceed(() => ({ @@ -222,6 +296,12 @@ export const make = Effect.gen(function* () { if (!environment.isPackaged) return yield* fail("The t3 command needs an installed app."); yield* ensureLauncher; if (windows) { + // PATH would split the folder at the `;`, leaving two wrong entries behind. + if (binDirectory.includes(";")) { + return yield* fail( + `T3 Code cannot put ${binDirectory} on PATH because it has a ";". Run ${launcher} directly.`, + ); + } const entries = pathEntries(yield* readUserPath, ";"); if (!entries.some((entry) => sameWindowsPath(entry, binDirectory))) { yield* writeUserPath([...entries, binDirectory].join(";")); @@ -231,26 +311,27 @@ export const make = Effect.gen(function* () { } return yield* state; } - const existing = yield* installedAt; - if (Option.isSome(existing)) { - const target = yield* fs.readLink(existing.value).pipe(Effect.option); + const candidates = userCandidates(environment.homeDirectory); + const replaced = Option.getOrUndefined(yield* installedAt); + if (replaced !== undefined) { + const target = yield* fs.readLink(replaced).pipe(Effect.option); if (Option.getOrUndefined(target) === launcher) return yield* state; - yield* fs - .remove(existing.value) - .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`))); } const shadowedBy = yield* foreignFirstOnPath; - if (Option.isSome(shadowedBy)) { + // Our own link into another T3 home runs first too; it is the one this install updates. + if (Option.isSome(shadowedBy) && shadowedBy.value !== replaced) { return yield* fail( `Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`, ); } - const onPath = pathEntries(env.PATH, ":"); - const candidates = unixCandidates(environment.homeDirectory, environment.platform); - for (const directory of [ + // An old link of ours is updated where it is, so a t3 that works now keeps working. It is + // removed only once a new link exists elsewhere. + const onPath = pathEntries(env.PATH, ":").map((entry) => entry.replace(/\/+$/, "")); + for (const directory of new Set([ + ...(replaced === undefined ? [] : [path.dirname(replaced)]), ...candidates.filter((candidate) => onPath.includes(candidate)), ...candidates.filter((candidate) => !onPath.includes(candidate)), - ]) { + ])) { const link = path.join(directory, "t3"); const created = (yield* exists(directory)) ? yield* writableDirectory(directory) @@ -259,18 +340,38 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); if (!created) continue; - const linked = yield* fs.symlink(launcher, link).pipe( + // Our own link is swapped in place; anything else there makes symlink fail and stays. + const staging = `${link}.${yield* Random.nextIntBetween(0, 0xffffff)}.tmp`; + const linked = yield* ( + link === replaced + ? fs + .symlink(launcher, staging) + .pipe( + Effect.andThen( + fs + .rename(staging, link) + .pipe(Effect.tapError(() => fs.remove(staging).pipe(Effect.ignore))), + ), + ) + : fs.symlink(launcher, link) + ).pipe( Effect.as(true), Effect.orElseSucceed(() => false), ); - if (linked) return yield* state; + if (!linked) continue; + if (replaced !== undefined && replaced !== link) { + yield* fs.remove(replaced).pipe(Effect.ignore); + } + return yield* state; } return yield* fail( - `Another t3 command is already installed, or no folder on your PATH is writable. Run the launcher directly at ${launcher}.`, + `Another t3 command is already in ~/.local/bin and ~/bin, or neither is writable. Run the launcher directly at ${launcher}.`, ); }).pipe(Effect.withSpan("desktop.cliCommand.install")); const uninstall: DesktopCliCommand["Service"]["uninstall"] = Effect.gen(function* () { + // A first launch put off for the shell PATH must not bring back what the user removed. + yield* fs.writeFileString(firstLaunchMarker, "").pipe(Effect.ignore); if (windows) { if (yield* exists(ownedPathMarker)) { const entries = pathEntries(yield* readUserPath, ";"); @@ -280,7 +381,7 @@ export const make = Effect.gen(function* () { } return yield* state; } - for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { + for (const directory of knownLocations(environment.homeDirectory, environment.platform)) { const link = path.join(directory, "t3"); if (yield* isOurLink(link)) { yield* fs.remove(link).pipe(Effect.mapError(() => fail(`Could not remove ${link}.`))); @@ -289,7 +390,79 @@ export const make = Effect.gen(function* () { return yield* state; }).pipe(Effect.withSpan("desktop.cliCommand.uninstall")); - return DesktopCliCommand.of({ state, install, uninstall }); + // One at a time, so Settings and onboarding cannot both append the line. + const addToPathPermit = yield* Semaphore.make(1); + const addToPath: DesktopCliCommand["Service"]["addToPath"] = Effect.gen(function* () { + const installed = yield* installedAt; + if (Option.isNone(installed) || !(yield* leadsHere(installed.value))) { + return yield* fail("Install the t3 command first."); + } + const setup = startupLine(installed.value); + if (setup === undefined) return yield* fail("T3 Code cannot add t3 to this shell's PATH."); + const shadowedBy = yield* foreignFirstOnPath; + if (Option.isSome(shadowedBy)) { + return yield* fail( + `Another t3 at ${shadowedBy.value} runs first; T3 Code leaves it in place.`, + ); + } + if (!(yield* folderOnPath(installed.value)) && !(yield* hasLine(setup))) { + yield* fs.makeDirectory(path.dirname(setup.path), { recursive: true }).pipe( + Effect.andThen( + fs.writeFileString(setup.path, `\n# Added by T3 Code\n${setup.line}\n`, { flag: "a" }), + ), + Effect.mapError(() => fail(`Could not add t3 to ${setup.file}.`)), + ); + } + return yield* state; + }).pipe(addToPathPermit.withPermits(1), Effect.withSpan("desktop.cliCommand.addToPath")); + + const installOnFirstLaunch: DesktopCliCommand["Service"]["installOnFirstLaunch"] = Effect.gen( + function* () { + if (!environment.isPackaged || (yield* exists(firstLaunchMarker))) return; + // Mark first so a failed or removed install is never retried behind the user's back. + yield* fs.writeFileString(firstLaunchMarker, ""); + if (!freshInstall) { + return yield* logInfo("left the t3 command to Settings on an existing install"); + } + // Another T3 home's app owns the existing link; only Install in Settings may take it over. + const existing = Option.getOrUndefined(yield* installedAt); + if (existing !== undefined) { + const target = yield* fs.readLink(existing).pipe(Effect.option); + if (Option.getOrUndefined(target) !== launcher) { + return yield* logInfo("left another T3 Code app's t3 command in place", { + existing, + }); + } + } + // A t3 that is not ours in these folders, even off PATH, means the user has one already. On + // Windows the app never writes there, so any t3 there is someone else's. + for (const directory of userCandidates(environment.homeDirectory)) { + for (const name of windows ? ["t3.cmd", "t3.exe"] : ["t3"]) { + const other = path.join(directory, name); + const present = + (yield* exists(other)) || Option.isSome(yield* fs.readLink(other).pipe(Effect.option)); + if (present && (windows || !(yield* isOurLink(other)))) { + return yield* logInfo("left the t3 command to Settings beside another t3", { other }); + } + } + } + const installed = yield* install; + yield* logInfo("installed the t3 command on first launch", { + installedPath: installed.installedPath, + onPath: installed.onPath, + }); + }, + ).pipe( + // Our own messages are short and name only paths; anything else is logged as a category. + Effect.catch((error) => + logWarning("did not install the t3 command on first launch", { + reason: error._tag === "DesktopCliCommandError" ? error.message : "state-write-failed", + }), + ), + Effect.withSpan("desktop.cliCommand.installOnFirstLaunch"), + ); + + return DesktopCliCommand.of({ state, install, uninstall, addToPath, installOnFirstLaunch }); }); export const layer = Layer.effect(DesktopCliCommand, make); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index 3d4b94abff462..f8bd8b5f91deb 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -2,6 +2,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Random from "effect/Random"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; @@ -147,7 +148,8 @@ export const install = Effect.gen(function* () { } if (Option.getOrUndefined(existing) !== content) { yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true }); - const staging = `${shimPath}.${process.pid}.tmp`; + // Unique per call: backend startup and the first-launch command install can race here. + const staging = `${shimPath}.${process.pid}.${yield* Random.nextIntBetween(0, 0xffffff)}.tmp`; yield* fs.writeFileString(staging, content, { mode: 0o755 }); yield* fs.rename(staging, shimPath); yield* logInfo("installed t3 launcher", { shimPath }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index e71d838188706..c1b9c060d547e 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -27,6 +27,7 @@ const layerShellEnvironment = (prepared = true) => Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(prepared), awaitDiscovery: Effect.succeed(prepared), + awaitUserShellDiscovery: Effect.succeed(prepared), updates: Stream.empty, }); diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9f8286a47ff98..9864c19b0d0ab 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -54,6 +54,21 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens only the bare Grok Bot launch link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.forEach( + ["grokbot://app", "grokbot://app/v1/prompt?id=bot&text=hi", "grokbot://"], + (url) => electronShell.openExternal(url), + ); + + assert.deepEqual(results, [true, false, false]); + assert.deepEqual(openExternalMock.mock.calls, [["grokbot://app"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens local editor file deep links", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 308f60b10c2cb..e342361750f41 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -21,6 +21,10 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); +// "Use T3 Code in another app" opens these apps (apps/web/src/components/mcp/mcpApps.ts). List +// each exact link shape the wizard sends, never a whole scheme. +const APP_LAUNCH_URLS = new Set(["grokbot://app"]); + // Zed embeds [user@]host in the path; reject passwords and ports there. const ZED_SSH_PATHNAME = /^\/(?:[^/@:]+@)?[^/@:]+\/.*$/; @@ -46,6 +50,7 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { try { const url = new URL(rawUrl); return SAFE_WEB_PROTOCOLS.has(url.protocol) || + APP_LAUNCH_URLS.has(url.href) || isRemoteEditorUrl(url) || isLocalEditorFileUrl(url) ? Option.some(url.href) diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index 478d490129014..9eb41ed4e2402 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -53,6 +53,15 @@ vi.mock("electron", () => ({ getAllWindows: getAllWindowsMock, getFocusedWindow: getFocusedWindowMock, }), + screen: { + // A 200% display, so the focus target must carry converted bounds. + dipToScreenRect: (_window: unknown, rect: Electron.Rectangle) => ({ + x: rect.x * 2, + y: rect.y * 2, + width: rect.width * 2, + height: rect.height * 2, + }), + }, })); import * as ElectronWindow from "./ElectronWindow.ts"; @@ -116,6 +125,13 @@ describe("ElectronWindow", () => { }); const electronWindow = yield* ElectronWindow.ElectronWindow; yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); + assert.deepEqual(windowsForegroundPrepareMock.mock.calls[0]?.[0], { + windowId: 41, + processId: process.pid, + title: "T3 Code (Dev)", + bounds: { x: 200, y: 100, width: 2_400, height: 1_600 }, + contentBounds: { x: 216, y: 100, width: 2_368, height: 1_584 }, + }); window.getTitle.mockClear(); const revealFiber = yield* electronWindow diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 985f9c00775a0..15a117a52b050 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -26,8 +26,9 @@ function windowsForegroundFocusTarget(window: Electron.BrowserWindow) { windowId: window.id, processId: process.pid, title: window.getTitle(), - bounds: window.getBounds(), - contentBounds: window.getContentBounds(), + // xa11y reports physical desktop pixels on Windows; Electron bounds are DIPs. + bounds: Electron.screen.dipToScreenRect(window, window.getBounds()), + contentBounds: Electron.screen.dipToScreenRect(window, window.getContentBounds()), }; } diff --git a/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts b/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts index a2d892a1c338a..3bdd5d63eefc5 100644 --- a/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts +++ b/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts @@ -32,13 +32,7 @@ async function findTarget( const children = await App.byPid(target.processId, { timeout: 0 }) .then((app) => app.children()) .catch(() => []); - return ( - children.find((candidate) => matchesTarget(candidate, target)) ?? - (await App.list()) - .filter((candidate) => candidate.pid === target.processId) - .map((candidate) => candidate.asElement()) - .find((candidate) => matchesTarget(candidate, target)) - ); + return children.find((candidate) => matchesTarget(candidate, target)); } const cachedElements = new Map(); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index db26c525c0015..6a1b1c62e7471 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -52,6 +52,7 @@ import { pickProjectFavicon, pickThemeFiles, setTheme, + syncHtmlFullscreen, showContextMenu, } from "./methods/window.ts"; import { @@ -80,6 +81,7 @@ import { getCliCommandState, installCliCommand, uninstallCliCommand, + addCliCommandToPath, } from "./methods/cliCommand.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { @@ -153,6 +155,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(checkSystemPermission); yield* ipc.handle(pasteAsText); yield* ipc.handle(revealWindow); + yield* ipc.handle(syncHtmlFullscreen); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); @@ -162,6 +165,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(getCliCommandState); yield* ipc.handle(installCliCommand); yield* ipc.handle(uninstallCliCommand); + yield* ipc.handle(addCliCommandToPath); for (const previewMethod of PreviewIpc.methods) { // Explicit types preserve the error/service union of these heterogeneous preview methods. yield* ipc.handle< diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 6a149e24ae695..aebbc1c24f8c2 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -19,6 +19,7 @@ export const TRACKPAD_SCROLL_BEGIN_CHANNEL = "desktop:trackpad-scroll-begin"; export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; +export const SYNC_HTML_FULLSCREEN_CHANNEL = "desktop:sync-html-fullscreen"; export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete"; export const DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL = "desktop:app-activation-request"; @@ -31,6 +32,7 @@ export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const CLI_COMMAND_GET_STATE_CHANNEL = "desktop:cli-command-get-state"; export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install"; export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; +export const CLI_COMMAND_ADD_TO_PATH_CHANNEL = "desktop:cli-command-add-to-path"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; diff --git a/apps/desktop/src/ipc/methods/cliCommand.ts b/apps/desktop/src/ipc/methods/cliCommand.ts index be9672c682517..b3731b8b6f9cb 100644 --- a/apps/desktop/src/ipc/methods/cliCommand.ts +++ b/apps/desktop/src/ipc/methods/cliCommand.ts @@ -32,3 +32,12 @@ export const uninstallCliCommand = makeIpcMethod({ return yield* (yield* DesktopCliCommand.DesktopCliCommand).uninstall; }), }); + +export const addCliCommandToPath = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_ADD_TO_PATH_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.addToPath")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).addToPath; + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 2ddc672891755..eebabd429025d 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -35,6 +35,7 @@ import { pickProjectFavicon, probeRemoteEditors, readMedia, + syncHtmlFullscreen, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -356,6 +357,93 @@ describe("pickProjectFavicon", () => { ); }); +describe("syncHtmlFullscreen", () => { + const fullscreenWindow = (fullscreen: boolean, { animatedExit = false } = {}) => { + let current = fullscreen; + let exiting = false; + const queued: boolean[] = []; + const leaveListeners: Array<() => void> = []; + const leave = () => { + current = false; + for (const listener of leaveListeners) listener(); + }; + // Like macOS, an animated exit keeps reporting fullscreen and queues requests until it ends. + const setFullScreen = vi.fn((next: boolean) => { + if (exiting) queued.push(next); + else if (next) current = true; + else if (!current) return; + else if (animatedExit) exiting = true; + else leave(); + }); + const finishExit = () => { + exiting = false; + leave(); + for (const next of queued.splice(0)) setFullScreen(next); + }; + const window = { + webContents: { id: 1 }, + isDestroyed: () => false, + isFullScreen: () => current, + setFullScreen, + on: (event: string, listener: () => void) => { + if (event === "leave-full-screen") leaveListeners.push(listener); + }, + } as unknown as Electron.BrowserWindow; + const layer = Layer.mock(ElectronWindow.ElectronWindow)({ main: Effect.succeedSome(window) }); + return { window, setFullScreen, finishExit, layer }; + }; + const fromMainRenderer = { sender: { id: 1 } }; + + it.effect("takes the screen for the app's own fullscreen element and gives it back", () => { + const { window, layer } = fullscreenWindow(false); + return Effect.gen(function* () { + yield* syncHtmlFullscreen.handler(true, fromMainRenderer); + assert.isTrue(window.isFullScreen()); + + yield* syncHtmlFullscreen.handler(false, fromMainRenderer); + assert.isFalse(window.isFullScreen()); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps a window the user had already made fullscreen", () => { + const { window, setFullScreen, layer } = fullscreenWindow(true); + return Effect.gen(function* () { + yield* syncHtmlFullscreen.handler(true, fromMainRenderer); + yield* syncHtmlFullscreen.handler(false, fromMainRenderer); + + assert.isTrue(window.isFullScreen()); + assert.strictEqual(setFullScreen.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }); + + it.effect("hands the window back once the user leaves OS fullscreen during playback", () => { + const { window, layer } = fullscreenWindow(false); + return Effect.gen(function* () { + yield* syncHtmlFullscreen.handler(true, fromMainRenderer); + window.setFullScreen(false); + window.setFullScreen(true); + yield* syncHtmlFullscreen.handler(false, fromMainRenderer); + + assert.isTrue(window.isFullScreen()); + }).pipe(Effect.provide(layer)); + }); + + it.effect("takes the screen again when the video returns during an animated exit", () => { + const { window, finishExit, layer } = fullscreenWindow(false, { animatedExit: true }); + return Effect.gen(function* () { + yield* syncHtmlFullscreen.handler(true, fromMainRenderer); + yield* syncHtmlFullscreen.handler(false, fromMainRenderer); + yield* syncHtmlFullscreen.handler(true, fromMainRenderer); + finishExit(); + assert.isTrue(window.isFullScreen()); + + yield* syncHtmlFullscreen.handler(false, fromMainRenderer); + finishExit(); + assert.isFalse(window.isFullScreen()); + }).pipe(Effect.provide(layer)); + }); +}); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "finds remote editors installed without PATH launchers", () => diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 9580b29b94bba..e0eea5c74c9ed 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -451,6 +451,56 @@ export const revealWindow = DesktopIpc.makeIpcMethod({ }), }); +const htmlFullscreenStates = new WeakMap< + Electron.BrowserWindow, + { owned: boolean; leaving: boolean } +>(); + +const htmlFullscreenState = (window: Electron.BrowserWindow) => { + const existing = htmlFullscreenStates.get(window); + if (existing !== undefined) return existing; + const state = { owned: false, leaving: false }; + window.on("leave-full-screen", () => { + // Leaving OS fullscreen any other way, such as F11, hands the window back to the user. + if (!state.leaving) state.owned = false; + state.leaving = false; + }); + htmlFullscreenStates.set(window, state); + return state; +}; + +// The main window keeps HTML fullscreen inside itself so preview guests stay in their webview, +// so the app's own fullscreen elements, such as chat videos, take the screen here. +export const syncHtmlFullscreen = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SYNC_HTML_FULLSCREEN_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.syncHtmlFullscreen")(function* (fullscreen, event) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const main = yield* electronWindow.main; + if ( + event === undefined || + Option.isNone(main) || + main.value.isDestroyed() || + main.value.webContents.id !== event.sender.id + ) { + return; + } + const window = main.value; + const state = htmlFullscreenState(window); + if (fullscreen) { + // macOS reports fullscreen until its exit animation ends, and Electron queues the re-entry. + if (state.owned || (window.isFullScreen() && !state.leaving)) return; + state.owned = true; + window.setFullScreen(true); + } else if (state.owned) { + state.owned = false; + state.leaving = window.isFullScreen(); + window.setFullScreen(false); + } + }), +}); + const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; export const pickThemeFiles = DesktopIpc.makeIpcMethod({ diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7c44ec5133785..2dbc4eeda3ae5 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -57,6 +57,18 @@ if (clientPlatform === "darwin") { window.addEventListener("resize", syncWindowControlInset); } +const syncHtmlFullscreen = () => { + const element = document.fullscreenElement; + // A preview guest's fullscreen surfaces here as its , which stays inside the window. + void ipcRenderer.invoke( + IpcChannels.SYNC_HTML_FULLSCREEN_CHANNEL, + element !== null && element.tagName !== "WEBVIEW", + ); +}; +// A reload or renderer crash never reports the old document's exit, so each document reports its start. +syncHtmlFullscreen(); +document.addEventListener("fullscreenchange", syncHtmlFullscreen); + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && @@ -285,6 +297,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL), install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL), uninstall: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL), + addToPath: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_ADD_TO_PATH_CHANNEL), }, onUpdateState: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 898d94221b8d4..720a57e64a9dc 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -199,6 +199,51 @@ describe("chromiumProcessIsAlive", () => { }); describe("isSourceInstalled", () => { + it.effect.each([undefined, "", "relative-config", "relocated", "chrome-override"])( + "discovers Linux Chromium profiles with XDG_CONFIG_HOME=%s", + (configHome) => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-xdg-" }); + const xdgConfigHome = + configHome === "relocated" || configHome === "chrome-override" + ? path.join(home, "config") + : configHome; + const chromeConfigHome = + configHome === "chrome-override" ? path.join(home, ".config") : undefined; + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { + HOME: home, + XDG_CONFIG_HOME: xdgConfigHome, + CHROME_CONFIG_HOME: chromeConfigHome, + }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const configRoot = + chromeConfigHome ?? + (configHome === "relocated" ? path.join(home, "config") : path.join(home, ".config")); + const source = BROWSER_IMPORT_SOURCES.find((source) => source.id === "chrome")!; + const root = path.join(configRoot, "google-chrome"); + const database = path.join(root, "Default", "Network", "Cookies"); + assert.isFalse(yield* isSourceInstalled(source, context)); + yield* fileSystem.makeDirectory(path.dirname(database), { recursive: true }); + yield* writeCookieDatabase(database, 2); + yield* fileSystem.writeFileString( + path.join(root, "Local State"), + '{"profile":{"info_cache":{"Default":{"name":"Fixture"}}}}', + ); + + assert.isTrue(yield* isSourceInstalled(source, context)); + assert.deepEqual(yield* listSourceProfiles(source, context), [ + { directory: "Default", name: "Fixture", cookieCount: 2 }, + ]); + assert.equal(yield* resolveCookieDatabase(source, context, "Default"), database); + }), + ), + ); + it.effect("ignores a user-data directory that holds no cookie database", () => run( Effect.gen(function* () { @@ -325,13 +370,18 @@ describe("Zen", () => { run( Effect.gen(function* () { const darwinContext = yield* sourcePathContext.pipe( - Effect.provideService(HostProcess.Environment, { HOME: "/Users/zen-user" }), + Effect.provideService(HostProcess.Environment, { + HOME: "/Users/zen-user", + XDG_CONFIG_HOME: "/custom-config", + }), Effect.provideService(HostProcess.Platform, "darwin"), ); const windowsContext = yield* sourcePathContext.pipe( Effect.provideService(HostProcess.Environment, { HOME: "C:\\Users\\zen-user", APPDATA: "C:\\Users\\zen-user\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\zen-user\\AppData\\Local", + XDG_CONFIG_HOME: "/custom-config", }), Effect.provideService(HostProcess.Platform, "win32"), ); @@ -359,6 +409,24 @@ describe("Zen", () => { linuxContext.path.join("/home/zen-user", ".zen"), linuxContext.path.join("/home/zen-user", ".var", "app", "app.zen_browser.zen", "zen"), ]); + assert.equal( + helium.userDataDirectory(darwinContext), + darwinContext.path.join( + "/Users/zen-user", + "Library", + "Application Support", + "net.imput.helium", + ), + ); + assert.equal( + helium.userDataDirectory(windowsContext), + windowsContext.path.join( + "C:\\Users\\zen-user\\AppData\\Local", + "imput", + "Helium", + "User Data", + ), + ); }), ), ); @@ -415,16 +483,19 @@ describe("Zen", () => { ), ); - it.effect("discovers Zen profiles in both current and legacy Linux roots", () => + it.effect("discovers Zen profiles in relocated and legacy Linux roots", () => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-linux-" }); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Environment, { + HOME: home, + XDG_CONFIG_HOME: NodePath.join(home, "config"), + }), Effect.provideService(HostProcess.Platform, "linux"), ); - const currentRoot = context.path.join(home, ".config", "zen"); + const currentRoot = context.path.join(home, "config", "zen"); const legacyRoot = context.path.join(home, ".zen"); const currentProfile = context.path.join(currentRoot, "current.default"); const legacyProfile = context.path.join(legacyRoot, "legacy.default"); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 3f94269bfe273..20a35752ed979 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -16,6 +16,8 @@ export interface BrowserImportPathContext { readonly path: Path.Path; readonly platform: NodeJS.Platform; readonly home: string; + readonly linuxConfigHome: string; + readonly linuxChromeConfigHome: string; readonly appData: string | undefined; readonly localAppData: string | undefined; } @@ -68,7 +70,10 @@ const chromiumSource = (input: { : undefined; } return input.linuxSegments - ? context.path.join(context.home, ".config", ...input.linuxSegments) + ? context.path.join( + input.id === "chrome" ? context.linuxChromeConfigHome : context.linuxConfigHome, + ...input.linuxSegments, + ) : undefined; }, }); @@ -185,7 +190,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray [ context.path.join(context.home, ".zen"), @@ -292,10 +297,18 @@ export const sourcePathContext = Effect.gen(function* () { const path = yield* Path.Path; const platform = yield* HostProcess.Platform; const environment = yield* HostProcess.Environment; + const home = environment.HOME ?? environment.USERPROFILE ?? ""; + const xdgConfigHome = environment.XDG_CONFIG_HOME; + const linuxConfigHome = + xdgConfigHome && path.isAbsolute(xdgConfigHome) ? xdgConfigHome : path.join(home, ".config"); + const chromeConfigHome = environment.CHROME_CONFIG_HOME; return { path, platform, - home: environment.HOME ?? environment.USERPROFILE ?? "", + home, + linuxConfigHome, + linuxChromeConfigHome: + chromeConfigHome && path.isAbsolute(chromeConfigHome) ? chromeConfigHome : linuxConfigHome, appData: environment.APPDATA, localAppData: environment.LOCALAPPDATA, } satisfies BrowserImportPathContext; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 26a6386a9abd2..b4e4b2a8b75ce 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,5 +1,6 @@ import { it as effectIt } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import type { DesktopPreviewRecordingInputEvent, PreviewAnnotationPayload, @@ -7,6 +8,7 @@ import type { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { parseKeybindingShortcut } from "@t3tools/shared/keybindings"; +import { app, shell } from "electron"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -169,6 +171,7 @@ const { })); vi.mock("electron", () => ({ + app: { getPath: vi.fn() }, BrowserWindow: browserWindowConstructor, // oxlint-disable-next-line typescript/no-extraneous-class -- This constructor-only mock implements the browser ClipboardItem API. ClipboardItem: class { @@ -571,6 +574,103 @@ describe("PreviewManager", () => { webviewSend.mockClear(); }); + effectIt.effect.each([false, true])( + "preserves simultaneous same-name downloads with an existing destination: %s", + (existing) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-download-test-" }); + const downloads = `${dir}/downloads`; + yield* fs.makeDirectory(downloads); + using _downloads = vi.spyOn(app, "getPath").mockReturnValue(downloads); + if (existing) yield* fs.writeFileString(`${downloads}/report.txt`, "existing download"); + const sources = [`${dir}/first`, `${dir}/second`]; + yield* fs.writeFileString(sources[0]!, "first download"); + yield* fs.writeFileString(sources[1]!, "second download"); + const checked = yield* Deferred.make(); + const finished = makeChangeSignal(); + let probes = 0; + let completions = 0; + using _reveal = vi.spyOn(shell, "showItemInFolder").mockImplementation(() => { + completions += 1; + finished.notify(); + }); + const raceTarget = `${downloads}/${existing ? "report (1).txt" : "report.txt"}`; + const exists = (target: string) => + Effect.gen(function* () { + const found = yield* fs.exists(target); + if (target === raceTarget) { + if (++probes === 2) yield* Deferred.succeed(checked, undefined); + yield* Deferred.await(checked); + } + return found; + }); + const manager = yield* PreviewManager.make.pipe( + Effect.provideService(FileSystem.FileSystem, { ...fs, exists }), + Effect.provide( + Layer.mergeAll( + layerEnvironment, + layerBrowserSession, + Layer.mock(DesktopBrowserHost.DesktopBrowserHost)({ + attach: () => {}, + detach: () => {}, + setShown: () => {}, + humanStartedDownload: () => true, + placeDownload: () => true, + }), + Layer.mock(PreviewPasskeys.PreviewPasskeys)({ + bridgeEnabled: false, + installSessionHandlers: () => {}, + attachGuest: () => () => {}, + }), + Layer.mock(DesktopRendererHistory.DesktopRendererHistory)({}), + Layer.succeed(HostProcess.Platform, "linux"), + ), + ), + ); + yield* manager.getBrowserSession("downloads"); + const listener = previewSession.on.mock.calls[0]![1] as ( + event: unknown, + item: { + once: (event: string, done: (event: unknown, state: string) => void) => void; + getFilename: () => string; + getSavePath: () => string; + }, + source: unknown, + ) => void; + for (const source of sources) { + listener( + {}, + { + once: (_event, done) => done({}, "completed"), + getFilename: () => "report.txt", + getSavePath: () => source, + }, + {}, + ); + } + yield* finished.until(() => completions === 2); + const names = yield* fs.readDirectory(downloads); + expect(names.toSorted()).toEqual( + existing + ? ["report (1).txt", "report (2).txt", "report.txt"] + : ["report (1).txt", "report.txt"], + ); + const contents = yield* Effect.forEach(names, (name) => + fs.readFileString(`${downloads}/${name}`), + ); + expect(contents.toSorted()).toEqual( + (existing + ? ["existing download", "first download", "second download"] + : ["first download", "second download"] + ).toSorted(), + ); + if (existing) { + expect(yield* fs.readFileString(`${downloads}/report.txt`)).toBe("existing download"); + } + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped), + ); + effectIt.effect.each([ ["linux", "ctrl+alt+[", "[", "BracketLeft", false], ["win32", "ctrl+alt+8", "[", "Digit8", false], @@ -824,6 +924,53 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("treats a load superseded by a page redirect as a successful navigation", () => + withManager((manager) => + Effect.gen(function* () { + // Same shape Electron's loadURL rejects with in lib/browser/api/web-contents.ts. + const loadError = (errno: number, code: string, url: string) => + Object.assign(new Error(`${code} (${errno}) loading '${url}'`), { errno, code, url }); + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_redirect"); + yield* manager.registerWebview("tab_redirect", 42); + + preview.loadURL.mockRejectedValueOnce( + loadError(-3, "ERR_ABORTED", "https://example.com/target"), + ); + yield* manager.navigate("tab_redirect", "https://example.com/"); + + preview.loadURL.mockRejectedValueOnce( + loadError(-3, "ERR_ABORTED", "https://example.com/reload"), + ); + yield* manager.navigate("tab_redirect", "https://example.com/reload"); + + preview.loadURL.mockImplementationOnce(async () => { + preview.listeners.get("will-prevent-unload")?.(); + throw loadError(-3, "ERR_ABORTED", "https://example.com/blocked"); + }); + const blocked = yield* Effect.exit( + manager.navigate("tab_redirect", "https://example.com/blocked"), + ); + expect(Exit.isFailure(blocked)).toBe(true); + + preview.loadURL.mockRejectedValueOnce( + loadError(-105, "ERR_NAME_NOT_RESOLVED", "https://missing.invalid/"), + ); + const failed = yield* Effect.exit( + manager.navigate("tab_redirect", "https://missing.invalid/"), + ); + expect(Exit.isFailure(failed)).toBe(true); + if (Exit.isSuccess(failed)) return; + expect(Option.getOrThrow(Cause.findErrorOption(failed.cause))).toMatchObject({ + _tag: "PreviewOperationError", + operation: "navigate.loadURL", + cause: { errno: -105 }, + }); + }), + ), + ); + effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 8c0904199921d..c668c39061e00 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.copyFile cannot request an exclusive copy. +import * as NodeFSP from "node:fs/promises"; import * as NodeZlib from "node:zlib"; import { DesktopPreviewAnnotationVoiceEventSchema, @@ -48,6 +50,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -2009,8 +2012,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } - yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () => - wc.loadURL(url), + let unloadPrevented = false; + const onPreventUnload = () => { + unloadPrevented = true; + }; + yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () => { + wc.on("will-prevent-unload", onPreventUnload); + return wc.loadURL(url); + }).pipe( + // Blocked unloads and superseding navigations both reject with ERR_ABORTED. + Effect.catchIf( + (error) => + !unloadPrevented && + Predicate.hasProperty(error.cause, "errno") && + error.cause.errno === -3, + () => Effect.void, + ), + Effect.ensuring(Effect.sync(() => wc.off("will-prevent-unload", onPreventUnload))), ); }); @@ -3670,7 +3688,18 @@ export const make = Effect.gen(function* PreviewManagerMake() { attempt === 0 ? `${stem}${extension}` : `${stem} (${attempt})${extension}`, ); if (yield* fileSystem.exists(target)) continue; - yield* fileSystem.copyFile(source, target); + const copied = yield* Effect.tryPromise({ + try: () => NodeFSP.copyFile(source, target, NodeFSP.constants.COPYFILE_EXCL), + catch: (cause) => new PreviewOperationError({ operation: "copyToDownloads", cause }), + }).pipe( + Effect.as(true), + Effect.catchIf( + (error) => + error.cause instanceof Error && "code" in error.cause && error.cause.code === "EEXIST", + () => Effect.succeed(false), + ), + ); + if (!copied) continue; shell.showItemInFolder(target); return; } diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index f54963d10916f..dcb16b851dc23 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -49,6 +49,16 @@ const MAX_MARQUEE_ELEMENTS = 20; const ELEMENT_CONTEXT_TIMEOUT_MS = 5_000; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; +// Page dialogs (Radix, react-remove-scroll) dismiss, refocus or block scrolling on document events +// that shadow retargeting attributes to the overlay host. +const PAGE_ISOLATED_EVENTS = [ + "pointerdown", + "focusin", + "focusout", + "wheel", + "touchstart", + "touchmove", +] as const; let recordingCursor: ReturnType | null = null; ipcRenderer.on( @@ -1549,6 +1559,13 @@ function startAnnotation( event.stopPropagation(); }; + const stopAtOverlay = (event: Event): void => event.stopPropagation(); + // Focus moving from the page into the overlay targets the page element, so the host never sees it. + const onFocusOutToOverlay = (event: FocusEvent): void => { + if (event.relatedTarget instanceof Element && isAnnotationNode(event.relatedTarget)) + event.stopPropagation(); + }; + const onPointerOut = (event: PointerEvent): void => { if (event.relatedTarget === null) clearHoverOutline(); }; @@ -1592,6 +1609,8 @@ function startAnnotation( dragHandle.removeEventListener("pointermove", onEditorPointerMove); dragHandle.removeEventListener("pointerup", onEditorPointerUp); dragHandle.removeEventListener("pointercancel", onEditorPointerUp); + for (const type of PAGE_ISOLATED_EVENTS) host.removeEventListener(type, stopAtOverlay); + document.documentElement.removeEventListener("focusout", onFocusOutToOverlay); if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); @@ -1743,6 +1762,10 @@ function startAnnotation( window.addEventListener("keyup", onVoiceKeyUp, { capture: true }); window.addEventListener("scroll", repaint, { capture: true, passive: true }); window.addEventListener("resize", repaint, { passive: true }); + // Bubble phase, so the overlay's own handlers still see these events. + for (const type of PAGE_ISOLATED_EVENTS) + host.addEventListener(type, stopAtOverlay, { passive: true }); + document.documentElement.addEventListener("focusout", onFocusOutToOverlay); ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); if (voice) ipcRenderer.on(ANNOTATION_VOICE_STATE_CHANNEL, onVoiceState); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts index 5b9fb3c13bc37..29f1865de291f 100644 --- a/apps/desktop/src/preview/RecordingCursor.ts +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -11,6 +11,11 @@ import { type RecordingKeyPress, } from "./RecordingInput.ts"; +// Modal dialogs, popovers and fullscreen elements paint above any z-index, so the cursors live in +// the top layer as manual popovers and move back to its front whenever the page adds to it. +const POPOVER_RESET = + "all:initial;margin:0;padding:0;border:0;background:transparent;overflow:visible;inset:auto;animation:none;transition:none;"; + /** Draw in guest coordinates; Chromium's native cursor ignores webview CSS placement/scale. Hide it to avoid duplicates. */ export function installRecordingCursor( document: Document, @@ -20,22 +25,43 @@ export function installRecordingCursor( ) { const style = document.createElement("style"); style.textContent = - "html, html * { cursor: none !important; } @media (prefers-reduced-motion: reduce) { [data-t3code-recording-agent-cursor] { transition: none !important; } }"; + "html, html * { cursor: none !important; } [data-t3code-recording-cursor]::backdrop, [data-t3code-recording-agent-cursor]::backdrop { display: none !important; } @media (prefers-reduced-motion: reduce) { [data-t3code-recording-agent-cursor] { transition: none !important; } }"; const cursor = document.createElement("div"); cursor.setAttribute("aria-hidden", "true"); cursor.setAttribute("data-t3code-recording-cursor", ""); + cursor.popover = "manual"; cursor.style.cssText = + POPOVER_RESET + "position:fixed;left:0;top:0;width:16px;height:24px;pointer-events:none;z-index:2147483647;display:none;"; cursor.innerHTML = ''; const agentCursor = document.createElement("div"); agentCursor.setAttribute("aria-hidden", "true"); agentCursor.setAttribute("data-t3code-recording-agent-cursor", ""); + agentCursor.popover = "manual"; agentCursor.style.cssText = + POPOVER_RESET + "position:fixed;left:0;top:0;width:20px;height:20px;pointer-events:none;z-index:2147483647;display:none;filter:drop-shadow(0 1px 2px #0003);transition:transform 150ms ease-out,opacity 150ms ease-out;"; agentCursor.innerHTML = ''; document.documentElement.append(style, cursor, agentCursor); + const cursors = [cursor, agentCursor]; + const raise = () => { + for (const element of cursors) { + if (!element.isConnected || typeof element.showPopover !== "function") continue; + if (element.matches(":popover-open")) element.hidePopover(); + element.showPopover(); + } + }; + raise(); + const dialogObserver = new MutationObserver(raise); + dialogObserver.observe(document, { subtree: true, attributes: true, attributeFilter: ["open"] }); + const toggle = (event: Event) => { + if (cursors.some((element) => element === event.target)) return; + if ("newState" in event && event.newState === "open") raise(); + }; + document.addEventListener("toggle", toggle, true); + document.addEventListener("fullscreenchange", raise); let controller: "human" | "agent" | "none" = "none"; let humanPoint: { readonly x: number; readonly y: number } | null = null; const drawHuman = () => { @@ -182,6 +208,9 @@ export function installRecordingCursor( window.removeEventListener("keyup", keyUp, true); window.removeEventListener("pointerout", leave, true); window.removeEventListener("blur", hide); + dialogObserver.disconnect(); + document.removeEventListener("toggle", toggle, true); + document.removeEventListener("fullscreenchange", raise); cancelPendingPointer(); window.clearTimeout(agentTimer); cursor.remove(); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 124dbbeae89e0..0c7869194323c 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -444,10 +444,13 @@ describe("DesktopShellEnvironment", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/failing", PATH: "/usr/bin" }; const commands: string[] = []; - yield* runShellEnvironment({ + const discovery = (service: DesktopShellEnvironment.DesktopShellEnvironment["Service"]) => + Effect.all([service.awaitDiscovery, service.awaitUserShellDiscovery]); + const answered = yield* runShellEnvironment({ env, platform: "linux", userShell: "/usr/local/bin/fish", + use: (service) => discovery(service).pipe(Effect.map(([, fromUserShell]) => fromUserShell)), handler: (command) => { if (command._tag !== "StandardCommand") return ""; commands.push(command.command); @@ -459,6 +462,23 @@ describe("DesktopShellEnvironment", () => { assert.deepEqual(commands, ["/bin/failing", "/usr/local/bin/fish"]); assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin"); + // The account's own login shell answered. + assert.isTrue(answered); + + // Only the platform fallback answered: its PATH can miss folders the user's shell adds. + const fallbackOnly = yield* runShellEnvironment({ + env: { SHELL: "/usr/bin/fish", PATH: "/usr/bin" }, + platform: "linux", + use: (service) => + discovery(service).pipe( + Effect.map(([prepared, fromUserShell]) => prepared && !fromUserShell), + ), + handler: (command) => + command._tag === "StandardCommand" && command.command === "/usr/bin/fish" + ? { output: "", exitCode: 7 } + : envOutput({ PATH: "/usr/bin" }), + }).pipe(Effect.provide(Logger.layer([]))); + assert.isTrue(fallbackOnly); }), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 37826f8ee5aba..9d9e390c95496 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,6 +3,7 @@ import { applyShellEnvironment, buildEnvironmentCaptureCommand, listLoginShellCandidates, + readUserLoginShell, setWindowsPath, } from "@t3tools/shared/shell"; import * as Context from "effect/Context"; @@ -79,6 +80,8 @@ export class DesktopShellEnvironment extends Context.Service< // Desktop owns discovery, including on cache misses; the backend receives updates over fd4. readonly installIntoProcess: Effect.Effect; readonly awaitDiscovery: Effect.Effect; + /** Whether the user's own login shell, not a fallback, gave the PATH a new terminal gets. */ + readonly awaitUserShellDiscovery: Effect.Effect; readonly updates: Stream.Stream; } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} @@ -417,19 +420,24 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return< - boolean, + ShellDiscovery, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem > { const fileSystem = yield* FileSystem.FileSystem; const shellEnvironment: EnvironmentPatch = {}; + const userShells = [config.env.SHELL?.trim(), readUserLoginShell()]; + let answeredBy: string | undefined; for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) { Object.assign( shellEnvironment, yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env), ); - if (shellEnvironment.PATH) break; + if (shellEnvironment.PATH) { + answeredBy = shell; + break; + } } const launchctlPath = @@ -518,24 +526,33 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } - return Option.isSome(trimNonEmpty(shellEnvironment.PATH)); + const prepared = Option.isSome(trimNonEmpty(shellEnvironment.PATH)); + return { prepared, fromUserShell: prepared && userShells.includes(answeredBy) }; }, ); +/** `fromUserShell` is false when only a fallback shell answered, whose PATH can miss folders. */ +interface ShellDiscovery { + readonly prepared: boolean; + readonly fromUserShell: boolean; +} + const installShellEnvironment = ( config: ShellEnvironmentConfig, ): Effect.Effect< - boolean, + ShellDiscovery, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem > => { if (config.platform === "win32") { - return installWindowsEnvironment(config); + return installWindowsEnvironment(config).pipe( + Effect.map((prepared) => ({ prepared, fromUserShell: prepared })), + ); } if (config.platform === "darwin" || config.platform === "linux") { return installPosixEnvironment(config); } - return Effect.succeed(false); + return Effect.succeed({ prepared: false, fromUserShell: false }); }; const ShellEnvironmentCache = Schema.fromJsonString( @@ -578,7 +595,7 @@ export const make = Effect.gen(function* () { const cachePath = path.join(environment.stateDir, "shell-environment-cache.json"); const changes = yield* PubSub.unbounded(); const latest = yield* Ref.make>(Option.none()); - const discovered = yield* Deferred.make(); + const discovered = yield* Deferred.make(); const publish = Effect.fn("desktop.shellEnvironment.publish")(function* ( patch: ShellEnvironmentPatch, discovered = false, @@ -597,7 +614,11 @@ export const make = Effect.gen(function* () { const refresh = Effect.gen(function* () { // Refresh from the inherited baseline so removed cached paths and values do not survive. const fresh = { ...inherited }; - const prepared = yield* installShellEnvironment({ env: fresh, platform: environment.platform }); + const discovery = yield* installShellEnvironment({ + env: fresh, + platform: environment.platform, + }); + const { prepared } = discovery; const patch = Object.fromEntries( Object.keys(ShellEnvironmentPatch.fields).map((name) => [name, fresh[name] ?? null]), ); @@ -616,7 +637,7 @@ export const make = Effect.gen(function* () { Effect.catch(() => Effect.logWarning("Could not cache shell environment")), ); } - return prepared; + return discovery; }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -669,7 +690,14 @@ export const make = Effect.gen(function* () { ); return DesktopShellEnvironment.of({ installIntoProcess, - awaitDiscovery: installIntoProcess.pipe(Effect.andThen(Deferred.await(discovered))), + awaitDiscovery: installIntoProcess.pipe( + Effect.andThen(Deferred.await(discovered)), + Effect.map(({ prepared }) => prepared), + ), + awaitUserShellDiscovery: installIntoProcess.pipe( + Effect.andThen(Deferred.await(discovered)), + Effect.map(({ fromUserShell }) => fromUserShell), + ), updates, }); }); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 59d658e1eedaf..57a38d2ff314d 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -978,7 +978,7 @@ it.effect.each(["succeeds", "fails"] as const)( }, ); -it.effect("matches Windows accessibility windows on a scaled display", () => { +it.effect("captures and reads Windows windows in physical pixels on a scaled display", () => { const png = Buffer.from([1, 2, 3]); const active = { platform: "windows", @@ -987,26 +987,26 @@ it.effect("matches Windows accessibility windows on a scaled display", () => { owner: { name: "Editor", processId: 123 }, bounds: { x: 10, y: 20, width: 800, height: 600 }, } as const; - const dipBounds = { x: 5, y: 10, width: 400, height: 300 }; + const window = (name: string, value: string) => ({ + role: "window", + name, + bounds: active.bounds, + children: async () => [ + { + role: "button", + name: "Save", + bounds: { x: 210, y: 220, width: 200, height: 100 }, + children: async () => [], + }, + ], + tree: async () => ({ name, value, children: [] }), + }); activeWindowMock.mockReset().mockResolvedValue(active); - screenToDipRectMock.mockImplementation(() => dipBounds); + screenToDipRectMock.mockImplementation(() => ({ x: 5, y: 10, width: 400, height: 300 })); regionCaptureMock.mockReset().mockResolvedValue({ width: 800, height: 600, png }); accessibilityForegroundMock.mockReset().mockResolvedValue({ pid: 123, - asElement: () => ({ - role: "window", - name: "Editor", - bounds: dipBounds, - children: async () => [ - { - role: "button", - name: "Save", - bounds: { x: 105, y: 110, width: 100, height: 50 }, - children: async () => [], - }, - ], - tree: async () => ({ name: "Editor", value: "Scaled text", children: [] }), - }), + children: async () => [window("Settings", "Wrong window"), window("Editor", "Scaled text")], }); let metadata = ""; return Effect.scoped( @@ -1015,6 +1015,7 @@ it.effect("matches Windows accessibility windows on a scaled display", () => { yield* service.configure(enabledSettings()); yield* service.capture; const saved = yield* decodePendingMetadata(metadata); + assert.deepEqual(regionCaptureMock.mock.calls[0]?.[0], active.bounds); assert.include(saved.source.accessibleText, "Scaled text"); assert.deepEqual( saved.source.accessibility?.format === "element-tree" @@ -1402,7 +1403,7 @@ it.each(["darwin", "win32", "linux"] as const)( accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [window] }); accessibilityForegroundMock .mockReset() - .mockResolvedValue({ pid: 123, asElement: () => window }); + .mockResolvedValue({ pid: 123, children: async () => [window] }); const result = await readAccessibleWindowContext( { title: "Editor", bounds, owner: { processId: 123 } }, @@ -1442,7 +1443,7 @@ it.each(["darwin", "win32"] as const)( accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [window] }); accessibilityForegroundMock .mockReset() - .mockResolvedValue({ pid: 123, asElement: () => window }); + .mockResolvedValue({ pid: 123, children: async () => [window] }); try { assert.isUndefined( await readAccessibleWindowText( @@ -1484,7 +1485,7 @@ it.each([ accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [window] }); accessibilityForegroundMock .mockReset() - .mockResolvedValue({ pid: 123, asElement: () => window }); + .mockResolvedValue({ pid: 123, children: async () => [window] }); try { const result = await readAccessibleWindowContext( { title: "Editor", bounds, owner: { processId: 123 } }, diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 53799e1577939..d995758619bfe 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -508,7 +508,6 @@ async function captureSource({ ({ source, png } = await captureRegionWindowSnapshot( regionSnapShotPool, active, - snapShotFlashBounds(active, platform), snapShotThumbnailSize(active), )); } else { @@ -547,7 +546,7 @@ async function captureSource({ } } const accessibleIdentity = active - ? { ...active, bounds: snapShotFlashBounds(active, platform) } + ? active : linuxWindow?.processId ? { title: linuxWindow.title, diff --git a/apps/desktop/src/snapShot/RegionSnapShot.ts b/apps/desktop/src/snapShot/RegionSnapShot.ts index c640198524af0..d354f41acf3f3 100644 --- a/apps/desktop/src/snapShot/RegionSnapShot.ts +++ b/apps/desktop/src/snapShot/RegionSnapShot.ts @@ -175,10 +175,9 @@ export function makeRegionSnapShotPool( export async function captureRegionWindowSnapshot( pool: Pick, active: ActiveWindow, - region: Electron.Rectangle, maxSize: Electron.Size, ): Promise<{ readonly source: RegionSnapShotSource; readonly png: Buffer }> { - const shot = await pool.capture(region); + const shot = await pool.capture(active.bounds); const scale = Math.min(maxSize.width / shot.width, maxSize.height / shot.height, 1); const png = scale < 1 diff --git a/apps/desktop/src/snapShot/SnapShotAccessibility.ts b/apps/desktop/src/snapShot/SnapShotAccessibility.ts index 9021c8904e3fe..3a6d3367c583f 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibility.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibility.ts @@ -125,7 +125,7 @@ async function readCapturedWindowAccessibility( const pidWindows = foreground !== undefined ? foreground.pid === active.owner.processId - ? [foreground.asElement()] + ? await foreground.children().catch(() => []) : [] : await windowsForPid(App, active.owner.processId); const matchMode = isWaylandSession(platform, process.env) ? "wayland" : "screen-bounds"; diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 9201bc01110e8..e2bfabe32762c 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -92,6 +92,7 @@ describe("DesktopTelemetryPublisher", () => { Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(true), awaitDiscovery: Effect.succeed(true), + awaitUserShellDiscovery: Effect.succeed(true), updates: Stream.make({ version: 1, type: "shellEnvironment", diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index c1bb89b759e6b..612ec441ba99b 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -4,7 +4,12 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type * as Electron from "electron"; +import * as Electron from "electron"; +import { vi } from "vite-plus/test"; + +vi.mock("electron", () => ({ + webContents: { getAllWebContents: vi.fn(() => []) }, +})); import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; @@ -227,10 +232,32 @@ describe("DesktopApplicationMenu", () => { items.find((item) => item.role === "windowMenu" && item.submenu === undefined), platform, ); - assert.isUndefined( - items.find((item) => item.accelerator?.toLowerCase().endsWith("+w")), - platform, - ); + const closeTools = items.find((item) => item.accelerator === "CmdOrCtrl+W"); + assert.equal(closeTools?.label, "Close Developer Tools", platform); + assert.isFunction(closeTools?.click); + const appContents = { + isDestroyed: () => false, + isDevToolsFocused: () => false, + closeDevTools: vi.fn(), + }; + const inspectedContents = { + ...appContents, + isDevToolsFocused: () => true, + closeDevTools: vi.fn(), + }; + using allContents = vi + .spyOn(Electron.webContents, "getAllWebContents") + .mockReturnValue([ + appContents as unknown as Electron.WebContents, + inspectedContents as unknown as Electron.WebContents, + ]); + closeTools?.click?.({} as Electron.MenuItem, undefined, {} as KeyboardEvent); + assert.equal(inspectedContents.closeDevTools.mock.calls.length, 1); + assert.equal(appContents.closeDevTools.mock.calls.length, 0); + + allContents.mockReturnValue([appContents as unknown as Electron.WebContents]); + closeTools?.click?.({} as Electron.MenuItem, undefined, {} as KeyboardEvent); + assert.equal(appContents.closeDevTools.mock.calls.length, 0); } }), ); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 07fd8654af6be..44d2c234f8358 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; @@ -200,6 +200,17 @@ export const make = Effect.gen(function* () { }, { type: "separator" as const }, ]), + { + label: "Close Developer Tools", + accelerator: "CmdOrCtrl+W", + visible: false, + click: () => { + Electron.webContents + .getAllWebContents() + .find((contents) => !contents.isDestroyed() && contents.isDevToolsFocused()) + ?.closeDevTools(); + }, + }, // Cmd/Ctrl+W belongs to the renderer's tab close; it must never close the window. environment.platform === "darwin" ? { label: "Close Window", click: (_item, window) => window?.close() } diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 4f8949823e354..03b9a2e6ad64a 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -94,6 +94,7 @@ function makeFakeBrowserWindow() { replaceMisspelling: vi.fn(), send: vi.fn(), setBackgroundThrottling: vi.fn(), + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), toggleDevTools: vi.fn(), }; @@ -143,6 +144,7 @@ function makeFakeBrowserWindow() { reloadIgnoringCache: webContents.reloadIgnoringCache, toggleDevTools: webContents.toggleDevTools, navigationHistory: webContents.navigationHistory, + setIgnoreMenuShortcuts: webContents.setIgnoreMenuShortcuts, send: webContents.send, setZoomLevel: webContents.setZoomLevel, setWindowButtonPosition: window.setWindowButtonPosition, @@ -479,6 +481,30 @@ describe("DesktopWindow", () => { ); }); + it("restores a window saved 1px past a display edge, and nothing further out", () => { + const displays = [ + { x: 0, y: 0, width: 1920, height: 1280 }, + { x: 1920, y: 0, width: 1280, height: 1920 }, + ]; + const snappedBounds = { x: 1921, y: 30, width: 1280, height: 945 }; + + assert.strictEqual( + DesktopWindow.resolveInitialMainWindowBounds(snappedBounds, displays), + snappedBounds, + ); + for (const bounds of [ + { x: 1922, y: 30, width: 1280, height: 945 }, + { x: 1918, y: 30, width: 1280, height: 945 }, + { x: 1921, y: -2, width: 1279, height: 945 }, + { x: 1920, y: 976, width: 1280, height: 946 }, + ]) { + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(bounds, displays), + DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE, + ); + } + }); + it("rejects renderer navigations whose origin only serializes as null", () => { for (const applicationUrl of ["t3code://app/", "t3code-dev://app/"]) { assert.isTrue( @@ -622,7 +648,7 @@ describe("DesktopWindow", () => { }), ); - it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => + it.effect("leaves Cmd+W to the renderer and blocks repeated presses", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); @@ -663,6 +689,13 @@ describe("DesktopWindow", () => { prevented = false; beforeInput(event, { ...input, meta: false }); assert.isFalse(prevented); + + assert.deepEqual( + fakeWindow.setIgnoreMenuShortcuts.mock.calls.map(([ignore]) => ignore), + [true, true, false], + ); + beforeInput(event, { ...input, key: "r", isAutoRepeat: false }); + assert.isFalse(fakeWindow.setIgnoreMenuShortcuts.mock.lastCall?.[0]); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 292a3540d18bf..17dba6742f795 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -148,15 +148,19 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { type DisplayBounds = Pick; +// A window snapped to a display edge can be reported 1px past that edge. +const SAVED_BOUNDS_EDGE_TOLERANCE_PX = 1; + function windowFitsWithinDisplay( windowBounds: DesktopAppSettings.DesktopWindowBounds, displayBounds: DisplayBounds, ): boolean { + const tolerance = SAVED_BOUNDS_EDGE_TOLERANCE_PX; return ( - windowBounds.x >= displayBounds.x && - windowBounds.y >= displayBounds.y && - windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width && - windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height + windowBounds.x >= displayBounds.x - tolerance && + windowBounds.y >= displayBounds.y - tolerance && + windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width + tolerance && + windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height + tolerance ); } @@ -417,6 +421,7 @@ export const make = Effect.gen(function* () { sandbox: true, webviewTag: true, // Guest fullscreen propagates to the embedder; both must opt out to keep it inside the webview. + // The app's own fullscreen elements reach OS fullscreen through syncHtmlFullscreen. disableHtmlFullscreenWindowResize: true, }, }); @@ -672,6 +677,13 @@ export const make = Effect.gen(function* () { }); window.webContents.on("before-input-event", (event, input) => { quitShortcutHandler(event, input); + // Cmd/Ctrl+W reaches the renderer; DevTools uses the native menu shortcut. + window.webContents.setIgnoreMenuShortcuts( + (environment.platform === "darwin" ? input.meta : input.control) && + !input.alt && + !input.shift && + input.key.toLowerCase() === "w", + ); // A held Cmd/Ctrl+W closes one tab or terminal, not each one behind it. if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 338b79c0b8f5c..45fb17a8e9622 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -1,5 +1,7 @@ import { defineConfig } from "astro/config"; +import { LOCALES } from "./src/i18n/locales.ts"; + // Astro loads env with an empty prefix, and under `vp run` Vite+'s loadEnv then reports every // variable to the task cache. Any env difference (TERM, VP_RAW_SUBCOMMAND, an agent's session // vars) would rerun `astro check`. Without the runner client, loadEnv reads process.env directly; @@ -10,6 +12,14 @@ export default defineConfig({ site: "https://t3.codes", // Vercel 308s `/path/` to `/path` (see vercel.ts), so every internal link and canonical uses one form. trailingSlash: "never", + // English stays unprefixed at `/`; see src/i18n/locales.ts for each locale's URL segment. + i18n: { + defaultLocale: "en", + locales: LOCALES.map(({ code, path }) => + !path || path === code ? code : { path, codes: [code] }, + ), + routing: { prefixDefaultLocale: false }, + }, server: { port: Number(process.env.PORT ?? 4173), }, diff --git a/apps/marketing/package.json b/apps/marketing/package.json index e37dd8fb7a894..04f6830066a1e 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -13,7 +13,7 @@ "@fontsource-variable/inter": "^5.3.0", "@t3tools/shared": "workspace:*", "astro": "^7.3.5", - "sharp": "0.35.4" + "sharp": "0.35.5" }, "devDependencies": { "@astrojs/check": "^0.9.10", diff --git a/apps/marketing/src/i18n/de.ts b/apps/marketing/src/i18n/de.ts new file mode 100644 index 0000000000000..648b35beebe07 --- /dev/null +++ b/apps/marketing/src/i18n/de.ts @@ -0,0 +1,100 @@ +import type { Messages } from "./en"; + +export const de: Messages = { + layout: { + defaultTitle: "T3 Code", + defaultDescription: "T3 Code. Die Open-Source-Steuerzentrale für Coding-Agents.", + socialImageAlt: + "T3 Code: die Open-Source-Steuerzentrale für Coding-Agents, mit Claude Code, Codex, Cursor, OpenCode, Grok und Antigravity.", + homeLabel: "Startseite von T3 Code", + download: "Download", + starsLabel: "T3 Code auf GitHub einen Stern geben, {stars} Sterne", + githubStars: "{stars} GitHub-Sterne", + language: "Sprache", + mitLicensed: "MIT-lizenziert", + iosApp: "iOS-App", + androidApp: "Android-App", + terms: "Nutzungsbedingungen", + privacy: "Datenschutz", + security: "Sicherheit", + }, + home: { + title: "T3 Code | Die Open-Source-Steuerzentrale für Coding-Agents", + description: "T3 Code. Die Open-Source-Steuerzentrale für Coding-Agents.", + heroTitle: ["Die Open-Source-", "Steuerzentrale", "für Coding-Agents."], + heroSub: + "Steuere Claude Code, Codex, Antigravity, OpenCode, Cursor und Grok von einer Oberfläche aus. Nutze dein eigenes Abo. Forke das ganze Ding.", + downloadForMac: "Für macOS herunterladen", + downloadForWindows: "Für Windows herunterladen", + downloadForLinux: "Für Linux herunterladen", + sourceLink: "Klau unseren Code (legal)", + alsoOnPhone: "Auch auf deinem Handy:", + screenshotAlt: "T3 Code führt Subagents von Claude, Codex, Muse und Gemini in einem Thread aus", + endorsementsTitle: "Toleriert von über {users} Devs", + endorsementsSub: "Manche haben sogar darüber getwittert.", + endorsementsLabel: "Stimmen von Entwicklern", + readPost: "Beitrag von {handle} auf X lesen", + harnessTitle: "Bring dein eigenes Abo mit", + harnessBody: + "T3 Code verkauft keine Tokens weiter. Verbinde Claude Code, Codex, Antigravity, OpenCode, Cursor oder Grok mit den Zugangsdaten, die du schon hast. Wir orchestrieren sie, du behältst dein Abo.", + googleSignIn: "Google-Anmeldung", + harnessPoints: [ + "Keine weiterverkauften Keys. Keine Kontingentgrenzen.", + "Modell mitten im Thread wechseln.", + "Jede Woche neue Agents.", + ], + gitVisualLabel: + "Illustration des Git-Workflows in T3 Code: Diff prüfen, Änderungen committen und pushen, Pull Request öffnen.", + prReady: "Bereit", + prCommits: "3 Commits", + viewDiff: "Diff ansehen", + openPullRequest: "Pull Request öffnen", + commitPush: "Commit & Push", + gitTitle: "Ein Klick für Commit, Push und PR.", + gitBody: + "Jeder Agent-Thread schreibt in seinen eigenen Branch. Wenn alles passt, öffnet ein Klick den PR auf GitHub, mit generiertem Titel, Beschreibung und Changelog. Kein Terminal-Gefrickel nötig.", + gitPoints: [ + "Automatisch generierte PR-Titel und -Beschreibungen", + "Inline-Diff-Review vor dem Push", + "Draft-PRs, gestapelte PRs, PRs nachbessern", + "Funktioniert mit deinem bestehenden GitHub-Login", + ], + openTitle: "Wenn dir etwas nicht gefällt, forke es.", + openPoints: [ + { lead: "Ändere die UI.", rest: "Gestalte jede Oberfläche nach deinem Geschmack." }, + { lead: "Füge einen Agenten hinzu.", rest: "Binde eigene Tools, Modelle und Abläufe ein." }, + { + lead: "Veröffentliche deinen eigenen Build.", + rest: "Hoste ihn selbst oder vertreibe ihn als deinen eigenen.", + }, + ], + stars: "{stars} Sterne", + forkOnGithub: "Auf GitHub forken", + browseSource: "Quellcode ansehen", + ctaTitle: "Deine Agents verdienen Besseres als ein Terminal.", + ctaSub: + "T3 Code ist kostenlos, Open Source und schnell. Installier es, verbinde das Abo, für das du schon zahlst, und lass deine Agents loslegen.", + otherPlatforms: "Andere Plattformen", + }, + download: { + title: "T3 Code für macOS, Windows und Linux herunterladen | T3 Code", + description: + "Lade die offizielle Desktop-App von T3 Code für macOS, Windows oder Linux herunter, oder hol dir die Apps für iOS und Android. t3.codes ist die einzige offizielle Download-Seite.", + breadcrumb: "Download", + heading: "T3 Code herunterladen", + channelLabel: "Release-Kanal", + stable: "Stable", + nightly: "Nightly", + intelNote: "Mac mit Intel-Chip? {link}.", + intelLink: "Lade den x64-Build herunter", + armNote: "Auf ARM? Lade das {deb} oder {appimage} für arm64 herunter.", + mobile: "Mobil", + iphoneIpad: "iPhone & iPad", + terminal: "Terminal", + tryOnce: "Oder einmal ohne Installation ausprobieren:", + allBuilds: "Alle Builds findest du auf der {link}", + releasesPage: "GitHub-Releases-Seite", + builtAgo: "Erstellt {age}", + releasesFallback: "Releases", + }, +}; diff --git a/apps/marketing/src/i18n/en.ts b/apps/marketing/src/i18n/en.ts new file mode 100644 index 0000000000000..1769d81184c5b --- /dev/null +++ b/apps/marketing/src/i18n/en.ts @@ -0,0 +1,105 @@ +// Source strings. Every other locale must provide the same shape (see `Messages`). +// `{name}` placeholders are filled at render time and must survive translation. +export const en = { + layout: { + defaultTitle: "T3 Code", + defaultDescription: "T3 Code. The open-source control plane for coding agents.", + socialImageAlt: + "T3 Code: the open-source control plane for coding agents, with Claude Code, Codex, Cursor, OpenCode, Grok and Antigravity.", + homeLabel: "T3 Code home", + download: "Download", + starsLabel: "Star T3 Code on GitHub, {stars} stars", + githubStars: "{stars} GitHub stars", + language: "Language", + mitLicensed: "MIT licensed", + iosApp: "iOS app", + androidApp: "Android app", + terms: "Terms", + privacy: "Privacy", + security: "Security", + }, + home: { + title: "T3 Code | The open-source control plane for coding agents", + description: "T3 Code. The open-source control plane for coding agents.", + heroTitle: ["The open-source", "control plane", "for coding agents."], + heroSub: + "Orchestrate Claude Code, Codex, Antigravity, OpenCode, Cursor, and Grok from one surface. Bring your own subscription. Fork the whole thing.", + downloadForMac: "Download for macOS", + downloadForWindows: "Download for Windows", + downloadForLinux: "Download for Linux", + sourceLink: "Steal our code (legally)", + alsoOnPhone: "Also on your phone:", + screenshotAlt: "T3 Code running Claude, Codex, Muse, and Gemini subagents in one thread", + endorsementsTitle: "Tolerated by over {users} devs", + endorsementsSub: "Some of them even tweeted about it.", + endorsementsLabel: "Developer endorsements", + readPost: "Read {handle}'s post on X", + harnessTitle: "Bring your own sub", + harnessBody: + "T3 Code doesn't resell tokens. Plug in Claude Code, Codex, Antigravity, OpenCode, Cursor, or Grok with the credentials you already have. We orchestrate them, you keep your plan.", + googleSignIn: "Google sign-in", + harnessPoints: [ + "No keys resold. No quota caps.", + "Switch models mid-thread.", + "More harnesses shipping weekly.", + ], + gitVisualLabel: + "Illustration of the T3 Code Git workflow: review a diff, commit and push changes, and open a pull request.", + prReady: "Ready", + prCommits: "3 commits", + viewDiff: "View diff", + openPullRequest: "Open pull request", + commitPush: "Commit & push", + gitTitle: "One button to commit, push, and make a PR.", + gitBody: + "Every agent thread writes to its own branch. When it's good, one button opens the PR on GitHub with a generated title, body and changelog. No terminal dance required.", + gitPoints: [ + "Auto-generated PR titles & bodies", + "Inline diff review before you push", + "Draft PRs, stack PRs, amend PRs", + "Works with your existing GitHub auth", + ], + openTitle: "If you don't like something, fork it.", + openPoints: [ + { lead: "Change the UI.", rest: "Restyle every surface to match your taste." }, + { lead: "Add an agent.", rest: "Wire in your own tools, models, and flows." }, + { lead: "Ship your own build.", rest: "Self-host it or distribute it as your own." }, + ], + stars: "{stars} stars", + forkOnGithub: "Fork on GitHub", + browseSource: "Browse source", + ctaTitle: "Your agents deserve better than a terminal.", + ctaSub: + "T3 Code is free, open source and fast. Install it, plug in the harness you already pay for, and let your agents get to work.", + otherPlatforms: "Other platforms", + }, + download: { + title: "Download T3 Code for macOS, Windows, and Linux | T3 Code", + description: + "Download the official T3 Code desktop app for macOS, Windows, or Linux, or get the iOS and Android apps. t3.codes is the only official download site.", + breadcrumb: "Download", + heading: "Download T3 Code", + channelLabel: "Release channel", + stable: "Stable", + nightly: "Nightly", + intelNote: "On an Intel Mac? {link}.", + intelLink: "Download the x64 build", + armNote: "On ARM? Download the arm64 {deb} or {appimage}.", + mobile: "Mobile", + iphoneIpad: "iPhone & iPad", + terminal: "Terminal", + tryOnce: "Or try it once without installing:", + allBuilds: "All builds are on the {link}", + releasesPage: "GitHub releases page", + builtAgo: "Built {age}", + releasesFallback: "Releases", + }, +}; + +type Widen = T extends string + ? string + : T extends ReadonlyArray + ? ReadonlyArray> + : { readonly [Key in keyof T]: Widen }; + +export type Messages = Widen; diff --git a/apps/marketing/src/i18n/es.ts b/apps/marketing/src/i18n/es.ts new file mode 100644 index 0000000000000..cb4c28e9871fd --- /dev/null +++ b/apps/marketing/src/i18n/es.ts @@ -0,0 +1,98 @@ +import type { Messages } from "./en"; + +export const es: Messages = { + layout: { + defaultTitle: "T3 Code", + defaultDescription: + "T3 Code. El panel de control de código abierto para agentes de programación.", + socialImageAlt: + "T3 Code: el panel de control de código abierto para agentes de programación, con Claude Code, Codex, Cursor, OpenCode, Grok y Antigravity.", + homeLabel: "Inicio de T3 Code", + download: "Descargar", + starsLabel: "Dale una estrella a T3 Code en GitHub, {stars} estrellas", + githubStars: "{stars} estrellas en GitHub", + language: "Idioma", + mitLicensed: "Licencia MIT", + iosApp: "App para iOS", + androidApp: "App para Android", + terms: "Términos", + privacy: "Privacidad", + security: "Seguridad", + }, + home: { + title: "T3 Code | El panel de control de código abierto para agentes de programación", + description: "T3 Code. El panel de control de código abierto para agentes de programación.", + heroTitle: ["El panel de control", "de código abierto", "para agentes de código."], + heroSub: + "Orquesta Claude Code, Codex, Antigravity, OpenCode, Cursor y Grok desde un solo lugar. Usa tu propia suscripción. Haz un fork del proyecto entero.", + downloadForMac: "Descargar para macOS", + downloadForWindows: "Descargar para Windows", + downloadForLinux: "Descargar para Linux", + sourceLink: "Roba nuestro código (legalmente)", + alsoOnPhone: "También en tu teléfono:", + screenshotAlt: "T3 Code ejecutando subagentes de Claude, Codex, Muse y Gemini en un mismo hilo", + endorsementsTitle: "Tolerado por más de {users} desarrolladores", + endorsementsSub: "Algunos hasta tuitearon al respecto.", + endorsementsLabel: "Opiniones de desarrolladores", + readPost: "Leer la publicación de {handle} en X", + harnessTitle: "Trae tu propia suscripción", + harnessBody: + "T3 Code no revende tokens. Conecta Claude Code, Codex, Antigravity, OpenCode, Cursor o Grok con las credenciales que ya tienes. Nosotros los orquestamos, tú conservas tu plan.", + googleSignIn: "Inicio de sesión con Google", + harnessPoints: [ + "Sin reventa de claves. Sin límites de cuota.", + "Cambia de modelo a mitad del hilo.", + "Nuevas integraciones cada semana.", + ], + gitVisualLabel: + "Ilustración del flujo de Git en T3 Code: revisar un diff, hacer commit y push de los cambios y abrir un pull request.", + prReady: "Listo", + prCommits: "3 commits", + viewDiff: "Ver diff", + openPullRequest: "Abrir pull request", + commitPush: "Commit y push", + gitTitle: "Un botón para hacer commit, push y abrir un PR.", + gitBody: + "Cada hilo de agente escribe en su propia rama. Cuando está listo, un botón abre el PR en GitHub con título, descripción y changelog generados. Sin malabares en la terminal.", + gitPoints: [ + "Títulos y descripciones de PR generados automáticamente", + "Revisa el diff en la app antes de hacer push", + "Crea PRs en borrador, apílalos o modifícalos", + "Funciona con tu autenticación de GitHub actual", + ], + openTitle: "Si algo no te gusta, haz un fork.", + openPoints: [ + { lead: "Cambia la interfaz.", rest: "Rediseña cada pantalla a tu gusto." }, + { lead: "Añade un agente.", rest: "Integra tus propias herramientas, modelos y flujos." }, + { lead: "Publica tu propia versión.", rest: "Alójala tú mismo o distribúyela como tuya." }, + ], + stars: "{stars} estrellas", + forkOnGithub: "Hacer fork en GitHub", + browseSource: "Ver el código", + ctaTitle: "Tus agentes merecen algo mejor que una terminal.", + ctaSub: + "T3 Code es gratis, de código abierto y rápido. Instálalo, conecta la herramienta que ya pagas y deja que tus agentes se pongan a trabajar.", + otherPlatforms: "Otras plataformas", + }, + download: { + title: "Descarga T3 Code para macOS, Windows y Linux | T3 Code", + description: + "Descarga la app oficial de escritorio de T3 Code para macOS, Windows o Linux, o consigue las apps para iOS y Android. t3.codes es el único sitio oficial de descarga.", + breadcrumb: "Descargar", + heading: "Descargar T3 Code", + channelLabel: "Canal de versiones", + stable: "Estable", + nightly: "Nightly", + intelNote: "¿Tienes un Mac con Intel? {link}.", + intelLink: "Descarga la versión x64", + armNote: "¿Usas ARM? Descarga el {deb} o el {appimage} para arm64.", + mobile: "Móvil", + iphoneIpad: "iPhone y iPad", + terminal: "Terminal", + tryOnce: "O pruébalo sin instalar nada:", + allBuilds: "Todas las compilaciones están en la {link}", + releasesPage: "página de versiones de GitHub", + builtAgo: "Compilada {age}", + releasesFallback: "Versiones", + }, +}; diff --git a/apps/marketing/src/i18n/fr.ts b/apps/marketing/src/i18n/fr.ts new file mode 100644 index 0000000000000..8fd918efc815b --- /dev/null +++ b/apps/marketing/src/i18n/fr.ts @@ -0,0 +1,101 @@ +import type { Messages } from "./en"; + +export const fr: Messages = { + layout: { + defaultTitle: "T3 Code", + defaultDescription: "T3 Code. Le poste de pilotage open source pour agents de codage.", + socialImageAlt: + "T3 Code : le poste de pilotage open source pour agents de codage, avec Claude Code, Codex, Cursor, OpenCode, Grok et Antigravity.", + homeLabel: "Accueil T3 Code", + download: "Télécharger", + starsLabel: "Mettre une étoile à T3 Code sur GitHub, {stars} étoiles", + githubStars: "{stars} étoiles sur GitHub", + language: "Langue", + mitLicensed: "Licence MIT", + iosApp: "App iOS", + androidApp: "App Android", + terms: "Conditions", + privacy: "Confidentialité", + security: "Sécurité", + }, + home: { + title: "T3 Code | Le poste de pilotage open source pour agents de codage", + description: "T3 Code. Le poste de pilotage open source pour agents de codage.", + heroTitle: ["Le poste de pilotage", "open source", "pour agents de codage."], + heroSub: + "Orchestrez Claude Code, Codex, Antigravity, OpenCode, Cursor et Grok depuis une seule interface. Utilisez votre propre abonnement. Forkez tout le projet.", + downloadForMac: "Télécharger pour macOS", + downloadForWindows: "Télécharger pour Windows", + downloadForLinux: "Télécharger pour Linux", + sourceLink: "Volez notre code (légalement)", + alsoOnPhone: "Aussi sur votre téléphone :", + screenshotAlt: + "T3 Code exécutant des sous-agents Claude, Codex, Muse et Gemini dans un même fil", + endorsementsTitle: "Toléré par plus de {users} devs", + endorsementsSub: "Certains en ont même parlé sur X.", + endorsementsLabel: "Avis de développeurs", + readPost: "Lire le post de {handle} sur X", + harnessTitle: "Apportez votre abonnement", + harnessBody: + "T3 Code ne revend pas de tokens. Branchez Claude Code, Codex, Antigravity, OpenCode, Cursor ou Grok avec les identifiants que vous avez déjà. Nous les orchestrons, vous gardez votre abonnement.", + googleSignIn: "Connexion Google", + harnessPoints: [ + "Aucune clé revendue. Aucun quota imposé.", + "Changez de modèle en cours de fil.", + "De nouveaux agents chaque semaine.", + ], + gitVisualLabel: + "Illustration du flux Git de T3 Code : relire un diff, commiter et pousser les modifications, puis ouvrir une pull request.", + prReady: "Prête", + prCommits: "3 commits", + viewDiff: "Voir le diff", + openPullRequest: "Ouvrir la pull request", + commitPush: "Commit et push", + gitTitle: "Un bouton pour commiter, pousser et ouvrir une PR.", + gitBody: + "Chaque fil d'agent écrit dans sa propre branche. Quand c'est bon, un bouton ouvre la PR sur GitHub avec un titre, une description et un changelog générés. Plus besoin de jongler dans le terminal.", + gitPoints: [ + "Titres et descriptions de PR générés automatiquement", + "Revue du diff intégrée avant de pousser", + "PR en brouillon, empilées ou amendées", + "Fonctionne avec votre authentification GitHub actuelle", + ], + openTitle: "Si quelque chose ne vous plaît pas, forkez-le.", + openPoints: [ + { lead: "Changez l'interface.", rest: "Personnalisez chaque écran à votre goût." }, + { lead: "Ajoutez un agent.", rest: "Branchez vos propres outils, modèles et workflows." }, + { + lead: "Publiez votre propre version.", + rest: "Hébergez-le vous-même ou distribuez-le sous votre nom.", + }, + ], + stars: "{stars} étoiles", + forkOnGithub: "Forker sur GitHub", + browseSource: "Parcourir le code", + ctaTitle: "Vos agents méritent mieux qu'un terminal.", + ctaSub: + "T3 Code est gratuit, open source et rapide. Installez-le, branchez l'outil auquel vous êtes déjà abonné et laissez vos agents se mettre au travail.", + otherPlatforms: "Autres plateformes", + }, + download: { + title: "Télécharger T3 Code pour macOS, Windows et Linux | T3 Code", + description: + "Téléchargez l'app de bureau officielle T3 Code pour macOS, Windows ou Linux, ou les apps iOS et Android. t3.codes est le seul site de téléchargement officiel.", + breadcrumb: "Télécharger", + heading: "Télécharger T3 Code", + channelLabel: "Canal de publication", + stable: "Stable", + nightly: "Nightly", + intelNote: "Sur un Mac Intel ? {link}.", + intelLink: "Téléchargez la version x64", + armNote: "Sur ARM ? Téléchargez le {deb} ou l'{appimage} arm64.", + mobile: "Mobile", + iphoneIpad: "iPhone et iPad", + terminal: "Terminal", + tryOnce: "Ou essayez-le sans rien installer :", + allBuilds: "Toutes les versions sont sur la {link}", + releasesPage: "page des releases GitHub", + builtAgo: "Compilé {age}", + releasesFallback: "Versions", + }, +}; diff --git a/apps/marketing/src/i18n/index.ts b/apps/marketing/src/i18n/index.ts new file mode 100644 index 0000000000000..c62950cafe0c9 --- /dev/null +++ b/apps/marketing/src/i18n/index.ts @@ -0,0 +1,24 @@ +import { de } from "./de"; +import { en, type Messages } from "./en"; +import { es } from "./es"; +import { fr } from "./fr"; +import { ja } from "./ja"; +import { ko } from "./ko"; +import type { Locale } from "./locales"; +import { ptBR } from "./pt-BR"; +import { ru } from "./ru"; +import { zh } from "./zh"; + +const MESSAGES: Record = { + en, + es, + "zh-Hans": zh, + "pt-BR": ptBR, + fr, + de, + ja, + ko, + ru, +}; + +export const messagesFor = (locale: Locale): Messages => MESSAGES[locale]; diff --git a/apps/marketing/src/i18n/ja.ts b/apps/marketing/src/i18n/ja.ts new file mode 100644 index 0000000000000..55117b609ac08 --- /dev/null +++ b/apps/marketing/src/i18n/ja.ts @@ -0,0 +1,102 @@ +import type { Messages } from "./en"; + +export const ja: Messages = { + layout: { + defaultTitle: "T3 Code", + defaultDescription: + "T3 Code。コーディングエージェントのためのオープンソースのコントロールプレーン。", + socialImageAlt: + "T3 Code:コーディングエージェントのためのオープンソースのコントロールプレーン。Claude Code、Codex、Cursor、OpenCode、Grok、Antigravity に対応。", + homeLabel: "T3 Code ホーム", + download: "ダウンロード", + starsLabel: "GitHub で T3 Code にスターを付ける({stars} スター)", + githubStars: "GitHub スター {stars}", + language: "言語", + mitLicensed: "MIT ライセンス", + iosApp: "iOS アプリ", + androidApp: "Android アプリ", + terms: "利用規約", + privacy: "プライバシー", + security: "セキュリティ", + }, + home: { + title: "T3 Code | コーディングエージェントのためのオープンソースのコントロールプレーン", + description: "T3 Code。コーディングエージェントのためのオープンソースのコントロールプレーン。", + heroTitle: ["コーディングエージェントの", "オープンソース", "コントロールプレーン。"], + heroSub: + "Claude Code、Codex、Antigravity、OpenCode、Cursor、Grok をひとつの画面からオーケストレーション。サブスクリプションは手持ちのものをそのまま。まるごとフォークも自由です。", + downloadForMac: "macOS 版をダウンロード", + downloadForWindows: "Windows 版をダウンロード", + downloadForLinux: "Linux 版をダウンロード", + sourceLink: "コードを盗もう(合法的に)", + alsoOnPhone: "スマホでも使えます:", + screenshotAlt: + "ひとつのスレッドで Claude、Codex、Muse、Gemini のサブエージェントを動かす T3 Code", + endorsementsTitle: "{users} 人以上の開発者が我慢して使っています", + endorsementsSub: "ツイートまでしてくれた人もいます。", + endorsementsLabel: "開発者の声", + readPost: "X で {handle} の投稿を読む", + harnessTitle: "サブスクは持ち込みで", + harnessBody: + "T3 Code はトークンを転売しません。Claude Code、Codex、Antigravity、OpenCode、Cursor、Grok を、すでに持っている認証情報でつなぐだけ。オーケストレーションは私たちに任せて、プランは今のままで。", + googleSignIn: "Google でサインイン", + harnessPoints: [ + "キーの転売なし。使用量の上限なし。", + "スレッドの途中でモデルを切り替え。", + "対応エージェントは毎週追加。", + ], + gitVisualLabel: + "T3 Code の Git ワークフローの図:diff を確認し、変更をコミットしてプッシュし、プルリクエストを作成する。", + prReady: "準備完了", + prCommits: "3 コミット", + viewDiff: "diff を表示", + openPullRequest: "プルリクエストを作成", + commitPush: "コミット&プッシュ", + gitTitle: "ボタンひとつでコミット、プッシュ、PR 作成。", + gitBody: + "エージェントのスレッドはそれぞれ専用のブランチに書き込みます。仕上がったら、ボタンひとつで GitHub に PR を作成。タイトル、本文、変更履歴も自動で生成されます。ターミナルでの面倒な操作は不要です。", + gitPoints: [ + "PR のタイトルと本文を自動生成", + "プッシュ前にインラインで diff をレビュー", + "ドラフト PR、スタック PR、PR の修正", + "既存の GitHub 認証をそのまま利用", + ], + openTitle: "気に入らなければ、フォークすればいい。", + openPoints: [ + { lead: "UI を変える。", rest: "あらゆる画面を好みのスタイルに。" }, + { lead: "エージェントを追加する。", rest: "独自のツール、モデル、フローを組み込めます。" }, + { + lead: "自分のビルドを出す。", + rest: "セルフホストも、自分のアプリとして配布するのも自由です。", + }, + ], + stars: "{stars} スター", + forkOnGithub: "GitHub でフォーク", + browseSource: "ソースを見る", + ctaTitle: "あなたのエージェントに、ターミナルだけではもったいない。", + ctaSub: + "T3 Code は無料、オープンソース、そして高速。インストールして、契約中のツールをつなぐだけ。あとはエージェントにおまかせ。", + otherPlatforms: "ほかのプラットフォーム", + }, + download: { + title: "T3 Code をダウンロード(macOS、Windows、Linux)| T3 Code", + description: + "公式の T3 Code デスクトップアプリ(macOS、Windows、Linux)や、iOS・Android アプリをダウンロードできます。公式のダウンロードサイトは t3.codes だけです。", + breadcrumb: "ダウンロード", + heading: "T3 Code をダウンロード", + channelLabel: "リリースチャンネル", + stable: "安定版", + nightly: "Nightly", + intelNote: "Intel Mac をお使いですか? {link}。", + intelLink: "x64 版をダウンロード", + armNote: "ARM をお使いですか? arm64 版の {deb} または {appimage} をダウンロード。", + mobile: "モバイル", + iphoneIpad: "iPhone と iPad", + terminal: "ターミナル", + tryOnce: "インストールせずに試すなら:", + allBuilds: "すべてのビルドは {link} にあります", + releasesPage: "GitHub のリリースページ", + builtAgo: "{age}にビルド", + releasesFallback: "リリース", + }, +}; diff --git a/apps/marketing/src/i18n/ko.ts b/apps/marketing/src/i18n/ko.ts new file mode 100644 index 0000000000000..f2c09c0af06ac --- /dev/null +++ b/apps/marketing/src/i18n/ko.ts @@ -0,0 +1,97 @@ +import type { Messages } from "./en"; + +export const ko: Messages = { + layout: { + defaultTitle: "T3 Code", + defaultDescription: "T3 Code. 코딩 에이전트를 위한 오픈 소스 컨트롤 플레인.", + socialImageAlt: + "T3 Code: 코딩 에이전트를 위한 오픈 소스 컨트롤 플레인. Claude Code, Codex, Cursor, OpenCode, Grok, Antigravity 지원.", + homeLabel: "T3 Code 홈", + download: "다운로드", + starsLabel: "GitHub에서 T3 Code에 스타 주기, 현재 스타 {stars}", + githubStars: "GitHub 스타 {stars}", + language: "언어", + mitLicensed: "MIT 라이선스", + iosApp: "iOS 앱", + androidApp: "Android 앱", + terms: "이용약관", + privacy: "개인정보처리방침", + security: "보안", + }, + home: { + title: "T3 Code | 코딩 에이전트를 위한 오픈 소스 컨트롤 플레인", + description: "T3 Code. 코딩 에이전트를 위한 오픈 소스 컨트롤 플레인.", + heroTitle: ["코딩 에이전트용", "오픈 소스", "컨트롤 플레인."], + heroSub: + "Claude Code, Codex, Antigravity, OpenCode, Cursor, Grok을 한 화면에서 오케스트레이션하세요. 구독은 쓰던 그대로. 통째로 포크하세요.", + downloadForMac: "macOS용 다운로드", + downloadForWindows: "Windows용 다운로드", + downloadForLinux: "Linux용 다운로드", + sourceLink: "코드 훔쳐 가기(합법적으로)", + alsoOnPhone: "휴대폰에서도:", + screenshotAlt: "하나의 스레드에서 Claude, Codex, Muse, Gemini 서브에이전트를 실행 중인 T3 Code", + endorsementsTitle: "{users}명이 넘는 개발자가 참고 쓰는 중", + endorsementsSub: "몇몇은 트윗까지 했습니다.", + endorsementsLabel: "개발자 추천", + readPost: "X에서 {handle}님의 게시물 읽기", + harnessTitle: "쓰던 구독 그대로", + harnessBody: + "T3 Code는 토큰을 되팔지 않습니다. 이미 쓰고 있는 계정으로 Claude Code, Codex, Antigravity, OpenCode, Cursor, Grok을 연결하세요. 오케스트레이션은 저희가, 요금제는 그대로.", + googleSignIn: "Google 로그인", + harnessPoints: [ + "키 재판매 없음. 사용량 한도 없음.", + "스레드 도중 모델 전환.", + "매주 새 에이전트 추가.", + ], + gitVisualLabel: + "T3 Code Git 워크플로 그림: diff를 검토하고, 변경 사항을 커밋 및 푸시하고, 풀 리퀘스트를 엽니다.", + prReady: "준비됨", + prCommits: "커밋 3개", + viewDiff: "diff 보기", + openPullRequest: "풀 리퀘스트 열기", + commitPush: "커밋 & 푸시", + gitTitle: "버튼 하나로 커밋, 푸시, PR까지.", + gitBody: + "에이전트 스레드마다 별도 브랜치에서 작업합니다. 결과가 마음에 들면 버튼 하나로 GitHub에 PR이 열리고, 제목과 본문, 변경 로그까지 자동으로 생성됩니다. 터미널에서 씨름할 필요가 없습니다.", + gitPoints: [ + "PR 제목과 본문 자동 생성", + "푸시 전에 인라인 diff 리뷰", + "드래프트 PR, 스택 PR, PR 수정", + "기존 GitHub 인증 그대로 사용", + ], + openTitle: "마음에 안 들면 포크하세요.", + openPoints: [ + { lead: "UI를 바꾸세요.", rest: "모든 화면을 취향대로 꾸밀 수 있습니다." }, + { lead: "에이전트를 추가하세요.", rest: "나만의 도구, 모델, 워크플로를 연결하세요." }, + { lead: "나만의 빌드를 만드세요.", rest: "직접 호스팅하거나 내 이름으로 배포하세요." }, + ], + stars: "스타 {stars}", + forkOnGithub: "GitHub에서 포크", + browseSource: "소스 보기", + ctaTitle: "에이전트를 터미널에만 두기엔 아깝습니다.", + ctaSub: + "T3 Code는 무료이고, 오픈 소스이며, 빠릅니다. 설치하고, 이미 구독 중인 서비스를 연결한 뒤, 에이전트에게 일을 맡기세요.", + otherPlatforms: "다른 플랫폼", + }, + download: { + title: "macOS, Windows, Linux용 T3 Code 다운로드 | T3 Code", + description: + "macOS, Windows, Linux용 공식 T3 Code 데스크톱 앱을 다운로드하거나 iOS 및 Android 앱을 받으세요. 공식 다운로드 사이트는 t3.codes뿐입니다.", + breadcrumb: "다운로드", + heading: "T3 Code 다운로드", + channelLabel: "릴리스 채널", + stable: "안정판", + nightly: "Nightly", + intelNote: "Intel Mac을 쓰시나요? {link}.", + intelLink: "x64 빌드 다운로드", + armNote: "ARM을 쓰시나요? arm64 {deb} 또는 {appimage}를 다운로드하세요.", + mobile: "모바일", + iphoneIpad: "iPhone & iPad", + terminal: "터미널", + tryOnce: "설치 없이 한 번 실행해 보려면:", + allBuilds: "모든 빌드는 {link}에 있습니다", + releasesPage: "GitHub 릴리스 페이지", + builtAgo: "{age} 빌드됨", + releasesFallback: "릴리스", + }, +}; diff --git a/apps/marketing/src/i18n/locales.ts b/apps/marketing/src/i18n/locales.ts new file mode 100644 index 0000000000000..4a2935c8688c9 --- /dev/null +++ b/apps/marketing/src/i18n/locales.ts @@ -0,0 +1,51 @@ +// `code` is Astro's locale code and the BCP 47 tag for `` and hreflang. `path` is the URL +// segment. pt-BR lives at `/pt-br`: lowercase like every other path on the site, with the region +// kept so a future pt-PT can sit beside it. zh is Simplified Chinese. +export const LOCALES = [ + { code: "en", path: "", name: "English" }, + { code: "es", path: "es", name: "Español" }, + { code: "zh-Hans", path: "zh", name: "简体中文" }, + { code: "pt-BR", path: "pt-br", name: "Português (Brasil)" }, + { code: "fr", path: "fr", name: "Français" }, + { code: "de", path: "de", name: "Deutsch" }, + { code: "ja", path: "ja", name: "日本語" }, + { code: "ko", path: "ko", name: "한국어" }, + { code: "ru", path: "ru", name: "Русский" }, +] as const; + +export type Locale = (typeof LOCALES)[number]["code"]; + +export const DEFAULT_LOCALE: Locale = "en"; + +/** Pages that exist in every locale. Legal pages, the 404, and /95 stay English-only. */ +export const LOCALIZED_PATHS = ["/", "/download"] as const; + +export type LocalizedPath = (typeof LOCALIZED_PATHS)[number]; + +export const isLocalizedPath = (path: string): path is LocalizedPath => + (LOCALIZED_PATHS as ReadonlyArray).includes(path); + +export const localeInfo = (locale: Locale) => LOCALES.find((entry) => entry.code === locale)!; + +export const localizedHref = (locale: Locale, path: LocalizedPath): string => { + const prefix = localeInfo(locale).path; + if (!prefix) return path; + return path === "/" ? `/${prefix}` : `/${prefix}${path}`; +}; + +/** `getStaticPaths` entries for a `[...locale]` route: English at the root, the rest prefixed. */ +export const localeStaticPaths = () => + LOCALES.map((entry) => ({ + params: { locale: entry.path || undefined }, + props: { locale: entry.code as Locale }, + })); + +/** Splits `"On ARM? {deb} or {appimage}."` so callers can render each `{key}` as markup. */ +export const templateParts = (template: string): Array => + template + .split(/(\{\w+\})/) + .filter(Boolean) + .map((part) => (/^\{\w+\}$/.test(part) ? { key: part.slice(1, -1) } : part)); + +export const fillTemplate = (template: string, values: Record): string => + template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match); diff --git a/apps/marketing/src/i18n/pt-BR.ts b/apps/marketing/src/i18n/pt-BR.ts new file mode 100644 index 0000000000000..33841e839c527 --- /dev/null +++ b/apps/marketing/src/i18n/pt-BR.ts @@ -0,0 +1,101 @@ +import type { Messages } from "./en"; + +export const ptBR: Messages = { + layout: { + defaultTitle: "T3 Code", + defaultDescription: "T3 Code. O painel de controle open source para agentes de programação.", + socialImageAlt: + "T3 Code: o painel de controle open source para agentes de programação, com Claude Code, Codex, Cursor, OpenCode, Grok e Antigravity.", + homeLabel: "Página inicial do T3 Code", + download: "Baixar", + starsLabel: "Dê uma estrela ao T3 Code no GitHub, {stars} estrelas", + githubStars: "{stars} estrelas no GitHub", + language: "Idioma", + mitLicensed: "Licença MIT", + iosApp: "App para iOS", + androidApp: "App para Android", + terms: "Termos", + privacy: "Privacidade", + security: "Segurança", + }, + home: { + title: "T3 Code | O painel de controle open source para agentes de programação", + description: "T3 Code. O painel de controle open source para agentes de programação.", + heroTitle: ["O painel de controle", "open source para", "agentes de código."], + heroSub: + "Orquestre Claude Code, Codex, Antigravity, OpenCode, Cursor e Grok em um só lugar. Use a sua própria assinatura. Faça um fork do projeto inteiro.", + downloadForMac: "Baixar para macOS", + downloadForWindows: "Baixar para Windows", + downloadForLinux: "Baixar para Linux", + sourceLink: "Roube nosso código (legalmente)", + alsoOnPhone: "Também no seu celular:", + screenshotAlt: + "T3 Code executando subagentes do Claude, Codex, Muse e Gemini em uma mesma thread", + endorsementsTitle: "Tolerado por mais de {users} devs", + endorsementsSub: "Alguns até tuitaram sobre isso.", + endorsementsLabel: "Depoimentos de desenvolvedores", + readPost: "Ler o post de {handle} no X", + harnessTitle: "Traga sua própria assinatura", + harnessBody: + "O T3 Code não revende tokens. Conecte Claude Code, Codex, Antigravity, OpenCode, Cursor ou Grok com as credenciais que você já tem. Nós orquestramos, você mantém seu plano.", + googleSignIn: "Login com Google", + harnessPoints: [ + "Sem revenda de chaves. Sem limite de cota.", + "Troque de modelo no meio da thread.", + "Novos agentes toda semana.", + ], + gitVisualLabel: + "Ilustração do fluxo de Git no T3 Code: revisar um diff, fazer commit e push das alterações e abrir um pull request.", + prReady: "Pronto", + prCommits: "3 commits", + viewDiff: "Ver diff", + openPullRequest: "Abrir pull request", + commitPush: "Commit e push", + gitTitle: "Um botão para fazer commit, push e abrir um PR.", + gitBody: + "Cada thread de agente escreve no seu próprio branch. Quando estiver bom, um botão abre o PR no GitHub com título, descrição e changelog gerados. Sem malabarismo no terminal.", + gitPoints: [ + "Títulos e descrições de PR gerados automaticamente", + "Revisão do diff inline antes do push", + "PRs em rascunho, stacks de PRs e amend", + "Funciona com a sua autenticação atual do GitHub", + ], + openTitle: "Se não gostar de algo, faça um fork.", + openPoints: [ + { lead: "Mude a interface.", rest: "Redesenhe cada tela do seu jeito." }, + { lead: "Adicione um agente.", rest: "Integre suas próprias ferramentas, modelos e fluxos." }, + { + lead: "Lance sua própria versão.", + rest: "Hospede por conta própria ou distribua como sua.", + }, + ], + stars: "{stars} estrelas", + forkOnGithub: "Fazer fork no GitHub", + browseSource: "Ver o código", + ctaTitle: "Seus agentes merecem mais do que um terminal.", + ctaSub: + "O T3 Code é gratuito, open source e rápido. Instale, conecte a assinatura que você já tem e deixe seus agentes trabalharem.", + otherPlatforms: "Outras plataformas", + }, + download: { + title: "Baixe o T3 Code para macOS, Windows e Linux | T3 Code", + description: + "Baixe o app oficial de desktop do T3 Code para macOS, Windows ou Linux, ou os apps para iOS e Android. t3.codes é o único site oficial de download.", + breadcrumb: "Baixar", + heading: "Baixar o T3 Code", + channelLabel: "Canal de versões", + stable: "Estável", + nightly: "Nightly", + intelNote: "Tem um Mac com Intel? {link}.", + intelLink: "Baixe a versão x64", + armNote: "Usa ARM? Baixe o {deb} ou o {appimage} para arm64.", + mobile: "Mobile", + iphoneIpad: "iPhone e iPad", + terminal: "Terminal", + tryOnce: "Ou experimente uma vez sem instalar:", + allBuilds: "Todas as versões estão na {link}", + releasesPage: "página de releases do GitHub", + builtAgo: "Compilado {age}", + releasesFallback: "Versões", + }, +}; diff --git a/apps/marketing/src/i18n/ru.ts b/apps/marketing/src/i18n/ru.ts new file mode 100644 index 0000000000000..8fdeefdb9fadd --- /dev/null +++ b/apps/marketing/src/i18n/ru.ts @@ -0,0 +1,97 @@ +import type { Messages } from "./en"; + +export const ru: Messages = { + layout: { + defaultTitle: "T3 Code", + defaultDescription: "T3 Code. Открытый пульт управления ИИ-агентами для программирования.", + socialImageAlt: + "T3 Code: открытый пульт управления ИИ-агентами для программирования. Claude Code, Codex, Cursor, OpenCode, Grok и Antigravity.", + homeLabel: "Главная страница T3 Code", + download: "Скачать", + starsLabel: "Поставить звезду T3 Code на GitHub, звёзд: {stars}", + githubStars: "{stars} звёзд на GitHub", + language: "Язык", + mitLicensed: "Лицензия MIT", + iosApp: "Приложение для iOS", + androidApp: "Приложение для Android", + terms: "Условия", + privacy: "Конфиденциальность", + security: "Безопасность", + }, + home: { + title: "T3 Code | Открытый пульт управления ИИ-агентами для программирования", + description: "T3 Code. Открытый пульт управления ИИ-агентами для программирования.", + heroTitle: ["Открытый пульт", "управления", "ИИ-агентами для кода."], + heroSub: + "Управляйте Claude Code, Codex, Antigravity, OpenCode, Cursor и Grok из одного окна. Пользуйтесь своей подпиской. Форкайте всё целиком.", + downloadForMac: "Скачать для macOS", + downloadForWindows: "Скачать для Windows", + downloadForLinux: "Скачать для Linux", + sourceLink: "Украдите наш код (легально)", + alsoOnPhone: "Есть и для телефона:", + screenshotAlt: "T3 Code запускает субагентов Claude, Codex, Muse и Gemini в одном треде", + endorsementsTitle: "Нас терпят более {users} разработчиков", + endorsementsSub: "Некоторые даже написали об этом в X.", + endorsementsLabel: "Отзывы разработчиков", + readPost: "Читать пост {handle} в X", + harnessTitle: "Со своей подпиской", + harnessBody: + "T3 Code не перепродаёт токены. Подключайте Claude Code, Codex, Antigravity, OpenCode, Cursor или Grok с учётными данными, которые у вас уже есть. Мы управляем агентами, тариф остаётся вашим.", + googleSignIn: "Вход через Google", + harnessPoints: [ + "Никакой перепродажи ключей. Никаких лимитов.", + "Меняйте модель прямо посреди треда.", + "Новые агенты каждую неделю.", + ], + gitVisualLabel: + "Схема работы с Git в T3 Code: просмотр диффа, коммит и пуш изменений, создание пул-реквеста.", + prReady: "Готово", + prCommits: "3 коммита", + viewDiff: "Открыть дифф", + openPullRequest: "Создать пул-реквест", + commitPush: "Коммит и пуш", + gitTitle: "Одна кнопка для коммита, пуша и PR.", + gitBody: + "Каждый тред агента работает в своей ветке. Когда всё готово, одна кнопка открывает PR на GitHub со сгенерированным заголовком, описанием и списком изменений. Никаких плясок с терминалом.", + gitPoints: [ + "Автоматические заголовки и описания PR", + "Просмотр диффа перед пушем", + "Черновые PR, стеки PR, правки PR", + "Работает с вашей текущей авторизацией GitHub", + ], + openTitle: "Что-то не нравится? Форкните.", + openPoints: [ + { lead: "Измените интерфейс.", rest: "Перерисуйте каждый экран на свой вкус." }, + { lead: "Добавьте агента.", rest: "Подключите свои инструменты, модели и сценарии." }, + { lead: "Выпустите свою сборку.", rest: "Хостите сами или распространяйте как свою." }, + ], + stars: "{stars} звёзд", + forkOnGithub: "Форкнуть на GitHub", + browseSource: "Исходный код", + ctaTitle: "Ваши агенты заслуживают лучшего, чем терминал.", + ctaSub: + "T3 Code бесплатный, открытый и быстрый. Установите его, подключите подписку, за которую уже платите, и дайте агентам поработать.", + otherPlatforms: "Другие платформы", + }, + download: { + title: "Скачать T3 Code для macOS, Windows и Linux | T3 Code", + description: + "Скачайте официальное десктопное приложение T3 Code для macOS, Windows или Linux либо приложения для iOS и Android. Единственный официальный сайт для загрузки: t3.codes.", + breadcrumb: "Скачать", + heading: "Скачать T3 Code", + channelLabel: "Канал релизов", + stable: "Стабильный", + nightly: "Nightly", + intelNote: "Mac на Intel? {link}.", + intelLink: "Скачайте сборку x64", + armNote: "На ARM? Скачайте {deb} или {appimage} для arm64.", + mobile: "Телефон", + iphoneIpad: "iPhone и iPad", + terminal: "Терминал", + tryOnce: "Или попробуйте без установки:", + allBuilds: "Все сборки на {link}", + releasesPage: "странице релизов GitHub", + builtAgo: "Собрано {age}", + releasesFallback: "Релизы", + }, +}; diff --git a/apps/marketing/src/i18n/zh.ts b/apps/marketing/src/i18n/zh.ts new file mode 100644 index 0000000000000..bae711530051a --- /dev/null +++ b/apps/marketing/src/i18n/zh.ts @@ -0,0 +1,95 @@ +import type { Messages } from "./en"; + +export const zh: Messages = { + layout: { + defaultTitle: "T3 Code", + defaultDescription: "T3 Code:面向编程智能体的开源控制中心。", + socialImageAlt: + "T3 Code:面向编程智能体的开源控制中心,支持 Claude Code、Codex、Cursor、OpenCode、Grok 和 Antigravity。", + homeLabel: "T3 Code 首页", + download: "下载", + starsLabel: "在 GitHub 上为 T3 Code 加星标,已有 {stars} 个星标", + githubStars: "{stars} GitHub 星标", + language: "语言", + mitLicensed: "MIT 许可", + iosApp: "iOS 应用", + androidApp: "Android 应用", + terms: "条款", + privacy: "隐私", + security: "安全", + }, + home: { + title: "T3 Code | 面向编程智能体的开源控制中心", + description: "T3 Code:面向编程智能体的开源控制中心。", + heroTitle: ["开源的", "编程智能体", "控制中心。"], + heroSub: + "在同一个界面里调度 Claude Code、Codex、Antigravity、OpenCode、Cursor 和 Grok。用你自己的订阅。整个项目都可以 fork。", + downloadForMac: "下载 macOS 版", + downloadForWindows: "下载 Windows 版", + downloadForLinux: "下载 Linux 版", + sourceLink: "来偷我们的代码(合法的)", + alsoOnPhone: "手机上也能用:", + screenshotAlt: "T3 Code 在同一个线程中运行 Claude、Codex、Muse 和 Gemini 子智能体", + endorsementsTitle: "超过 {users} 名开发者表示还能忍", + endorsementsSub: "有些人甚至发推夸了它。", + endorsementsLabel: "开发者评价", + readPost: "在 X 上阅读 {handle} 的帖子", + harnessTitle: "自带订阅", + harnessBody: + "T3 Code 不转售 token。用你现有的凭据接入 Claude Code、Codex、Antigravity、OpenCode、Cursor 或 Grok。我们负责调度,你的套餐还是你的。", + googleSignIn: "Google 登录", + harnessPoints: [ + "不转售密钥,不设额度上限。", + "线程进行中也能切换模型。", + "每周都有新的智能体接入。", + ], + gitVisualLabel: "T3 Code Git 工作流示意图:审查 diff、提交并推送更改、创建 PR。", + prReady: "就绪", + prCommits: "3 个提交", + viewDiff: "查看 diff", + openPullRequest: "创建 PR", + commitPush: "提交并推送", + gitTitle: "一键提交、推送并创建 PR。", + gitBody: + "每个智能体线程都写入自己的分支。满意之后,一键在 GitHub 上创建 PR,标题、描述和更新日志都会自动生成。不用在终端里来回折腾。", + gitPoints: [ + "自动生成 PR 标题和描述", + "推送前就地审查 diff", + "草稿 PR、堆叠 PR、修改 PR", + "沿用你现有的 GitHub 授权", + ], + openTitle: "哪里不喜欢,就 fork 它。", + openPoints: [ + { lead: "改 UI。", rest: "按你的喜好重新设计每个界面。" }, + { lead: "加智能体。", rest: "接入你自己的工具、模型和工作流。" }, + { lead: "发布你自己的版本。", rest: "自行托管,或以你自己的名义分发。" }, + ], + stars: "{stars} 星标", + forkOnGithub: "在 GitHub 上 fork", + browseSource: "浏览源码", + ctaTitle: "终端配不上你的智能体。", + ctaSub: "T3 Code 免费、开源、速度快。安装它,接入你已经订阅的智能体,然后让它们开工。", + otherPlatforms: "其他平台", + }, + download: { + title: "下载 T3 Code:macOS、Windows 和 Linux 版 | T3 Code", + description: + "下载 T3 Code 官方桌面应用(macOS、Windows 或 Linux),或获取 iOS 和 Android 应用。t3.codes 是唯一的官方下载网站。", + breadcrumb: "下载", + heading: "下载 T3 Code", + channelLabel: "发布通道", + stable: "稳定版", + nightly: "Nightly 版", + intelNote: "使用 Intel 芯片的 Mac?{link}。", + intelLink: "下载 x64 版本", + armNote: "ARM 架构?下载 arm64 版 {deb} 或 {appimage}。", + mobile: "移动端", + iphoneIpad: "iPhone 和 iPad", + terminal: "终端", + tryOnce: "或者无需安装,直接试用一次:", + allBuilds: "所有版本都在 {link}上", + releasesPage: "GitHub 发布页面", + builtAgo: "{age}构建", + releasesFallback: "发布页面", + }, +}; diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index c21c60b14527f..bd5d3e00a2f81 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -10,6 +10,17 @@ import { MARKETING_STATS, } from "../lib/site"; import { type JsonLd, jsonLdGraph, organizationJsonLd, websiteJsonLd } from "../lib/seo"; +import { messagesFor } from "../i18n"; +import { + DEFAULT_LOCALE, + fillTemplate, + LOCALES, + type Locale, + localeInfo, + type LocalizedPath, + localizedHref, + templateParts, +} from "../i18n/locales"; interface Props { title?: string; @@ -18,15 +29,31 @@ interface Props { /** Page-specific schema.org nodes. Organization and WebSite are always included. */ jsonLd?: ReadonlyArray; noindex?: boolean; + locale?: Locale; + /** Set when the page exists in every locale; enables the language picker and hreflang links. */ + localizedPath?: LocalizedPath; } +const { locale = DEFAULT_LOCALE, localizedPath } = Astro.props; +const t = messagesFor(locale).layout; const { - title = "T3 Code", - description = "T3 Code. The open-source control plane for coding agents.", + title = t.defaultTitle, + description = t.defaultDescription, pageClass, jsonLd = [], noindex = false, } = Astro.props; +const currentLocale = localeInfo(locale); +const homeHref = localizedHref(locale, "/"); +const downloadHref = localizedHref(locale, "/download"); +// English-only pages still declare themselves, so every page carries hreflang and x-default. +const alternates = localizedPath + ? LOCALES.map((entry) => ({ + hreflang: entry.code, + href: new URL(localizedHref(entry.code, localizedPath), Astro.site).href, + })) + : [{ hreflang: DEFAULT_LOCALE, href: new URL(Astro.url.pathname, Astro.site).href }]; +const defaultAlternate = alternates.find((entry) => entry.hreflang === DEFAULT_LOCALE)!; const structuredData = jsonLdGraph([organizationJsonLd(), websiteJsonLd(), ...jsonLd]); // Link unfurlers require a 1200x630 JPG/PNG at an absolute URL. @@ -38,12 +65,11 @@ const socialImage = await getImage({ quality: 90, }); const socialImageUrl = new URL(socialImage.src, Astro.site); -const socialImageAlt = - "T3 Code: the open-source control plane for coding agents, with Claude Code, Codex, Cursor, OpenCode, Grok and Antigravity."; +const socialImageAlt = t.socialImageAlt; const canonicalUrl = new URL(Astro.url.pathname, Astro.site); --- - + @@ -69,6 +95,8 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); + {alternates.map((entry) => )} + {title} @@ -429,6 +504,88 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); flex-shrink: 0; } + .nav-lang { + position: relative; + } + + .nav-lang-trigger { + display: inline-flex; + align-items: center; + gap: 7px; + height: 36px; + padding: 0 10px; + color: var(--fg-muted); + font-size: 13px; + letter-spacing: -0.01em; + white-space: nowrap; + list-style: none; + cursor: pointer; + transition: color 0.18s ease; + } + + .nav-lang-trigger::-webkit-details-marker { + display: none; + } + + .nav-lang-trigger svg { + flex-shrink: 0; + } + + .nav-lang-trigger:hover, + .nav-lang[open] .nav-lang-trigger { + color: var(--fg); + } + + .nav-lang-chevron { + opacity: 0.6; + transition: transform 0.18s ease; + } + + .nav-lang[open] .nav-lang-chevron { + transform: rotate(180deg); + } + + .nav-lang-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 200px; + padding: 6px; + list-style: none; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg-card); + box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.65); + animation: fade-in 0.18s ease both; + } + + .nav-lang-menu a { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 7px 10px; + border-radius: 6px; + color: var(--fg-muted); + font-size: 13px; + white-space: nowrap; + transition: color 0.18s ease, background 0.18s ease; + } + + .nav-lang-menu a:hover { + color: var(--fg); + background: rgba(255, 255, 255, 0.04); + } + + .nav-lang-menu a[aria-current="true"] { + color: var(--fg); + } + + .nav-lang-menu svg { + color: var(--ok); + flex-shrink: 0; + } + .main { flex: 1; display: flex; @@ -494,6 +651,12 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); display: none; } + /* The trigger sits near the left on narrow screens, so the menu opens rightward. */ + .nav-lang-menu { + right: auto; + left: 0; + } + .footer { padding-bottom: max(32px, env(safe-area-inset-bottom)); } @@ -518,6 +681,16 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); gap: 12px; } + .nav-lang-current, + .nav-lang-chevron { + display: none; + } + + .nav-lang-trigger { + height: 34px; + padding: 0 8px; + } + .nav-stars { height: 34px; gap: 6px; diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/[...locale]/download.astro similarity index 89% rename from apps/marketing/src/pages/download.astro rename to apps/marketing/src/pages/[...locale]/download.astro index c6630fa8a258f..5a3828f968865 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/[...locale]/download.astro @@ -1,12 +1,19 @@ --- import type { ComponentProps } from "astro/types"; import { Image } from "astro:assets"; -import iconStable from "../assets/icon.webp"; -import iconNightly from "../assets/icon-nightly.webp"; -import Layout from "../layouts/Layout.astro"; -import { RELEASES_URL } from "../lib/releases"; -import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site"; -import { breadcrumbJsonLd, softwareJsonLd } from "../lib/seo"; +import iconStable from "../../assets/icon.webp"; +import iconNightly from "../../assets/icon-nightly.webp"; +import Layout from "../../layouts/Layout.astro"; +import { RELEASES_URL } from "../../lib/releases"; +import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../../lib/site"; +import { breadcrumbJsonLd, softwareJsonLd } from "../../lib/seo"; +import { messagesFor } from "../../i18n"; +import { localeStaticPaths, localizedHref, templateParts } from "../../i18n/locales"; + +export const getStaticPaths = localeStaticPaths; + +const { locale } = Astro.props; +const t = messagesFor(locale).download; const imageProps = { alt: "", @@ -18,17 +25,25 @@ const imageProps = { --- -
+
@@ -36,12 +51,12 @@ const imageProps = { -

Download T3 Code

+

{t.heading}

-
+
- - + +

@@ -69,7 +84,9 @@ const imageProps = {

- On an Intel Mac? Download the x64 build. + {templateParts(t.intelNote).map((part) => + typeof part === "string" ? part : {t.intelLink}, + )}

@@ -120,8 +137,11 @@ const imageProps = {

- On ARM? Download the arm64 .deb or - AppImage. + {templateParts(t.armNote).map((part) => + typeof part === "string" ? part + : part.key === "deb" ? .deb + : AppImage, + )}

@@ -129,12 +149,12 @@ const imageProps = {
-

Mobile

+

{t.mobile}

- iPhone & iPad + {t.iphoneIpad} App Store @@ -150,29 +170,32 @@ const imageProps = {
-

Terminal

+

{t.terminal}

curl -fsSL https://t3.codes/install.sh | sh irm https://t3.codes/install.ps1 | iex curl -fsSL https://t3.codes/install.sh | T3CODE_CHANNEL=nightly sh $env:T3CODE_CHANNEL="nightly"; irm https://t3.codes/install.ps1 | iex -

Or try it once without installing:

+

{t.tryOnce}

npx t3@latest npx t3@nightly
& > 😀\u00a0end\n", + ); + const html = selectableSourceHtml(source.contents, { + foreground: "#000000", + selection: "#3366ff", + fontSize: 14, + rowHeight: 24, + wordBreak: true, + }); + const page = new DOMParser().parseFromString(html, "text/html"); + + expect(page.querySelector("script")).toBeNull(); + expect(page.body.textContent).toBe("\n\tlead\n & > 😀\u00a0end\n"); + expect(page.getElementById("L2")?.textContent).toBe("\tlead"); +}); diff --git a/apps/mobile/src/features/files/source-file-document.ts b/apps/mobile/src/features/files/source-file-document.ts index 2c4c275b85a18..98c10de0d12fd 100644 --- a/apps/mobile/src/features/files/source-file-document.ts +++ b/apps/mobile/src/features/files/source-file-document.ts @@ -66,3 +66,28 @@ export function boundedSelectableSourceTokens( } return tokens; } + +export interface SelectableSourceHtmlStyle { + readonly foreground: string; + readonly selection: string; + readonly fontSize: number; + readonly rowHeight: number; + readonly wordBreak: boolean; +} + +// A
 would drop a leading newline, so whitespace is preserved on a plain 
. +export function selectableSourceHtml(contents: string, style: SelectableSourceHtmlStyle): string { + const text = contents + .split("\n") + .map( + (line, index) => + `${line.replace(/[&<>]/g, (char) => + char === "&" ? "&" : char === "<" ? "<" : ">", + )}`, + ) + .join("\n"); + const wrap = style.wordBreak + ? "white-space:pre-wrap;overflow-wrap:anywhere" + : "white-space:pre;width:max-content"; + return `
${text}
`; +} diff --git a/apps/mobile/src/features/files/useFileTreeEntries.test.tsx b/apps/mobile/src/features/files/useFileTreeEntries.test.tsx index 4076dd865fa8d..e7f0f2d4136af 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.test.tsx +++ b/apps/mobile/src/features/files/useFileTreeEntries.test.tsx @@ -141,7 +141,10 @@ vi.mock("react-native", () => ({ vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0 }) })); vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: Nothing })); vi.mock("../../native/native-layout-metrics", () => ({ useNativeColumnLayoutMetrics: () => null })); -vi.mock("../../components/ui/useBarInset", () => ({ useContentTopInset: () => 0 })); +vi.mock("../../components/ui/useBarInset", () => ({ + useContentTopInset: () => 0, + useContentBottomInset: () => 0, +})); vi.mock("@legendapp/list/react-native", () => ({ LegendList: (props: { data: ReadonlyArray; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 99ede8afe7c4a..f1b163461b6c7 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -19,7 +19,10 @@ import { environmentThreadShells } from "../../state/threads"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { + useAdaptiveWorkspaceLayout, + useWorkspacePaneState, +} from "../layout/AdaptiveWorkspaceLayout"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { useHomeListOptions } from "./home-list-options"; @@ -37,8 +40,8 @@ import { buildHomeListFilterMenuItems } from "./home-list-filter-menu"; export function HomeRouteScreen() { const nativePrimaryColumn = use(NativePrimaryColumnContext); - const { layout, primarySidebarSearchQuery, setPrimarySidebarSearchQuery, startNewTask } = - useAdaptiveWorkspaceLayout(); + const { layout, setPrimarySidebarSearchQuery, startNewTask } = useAdaptiveWorkspaceLayout(); + const { primarySidebarSearchQuery } = useWorkspacePaneState(); const selectDetail = useSelectWorkspaceDetail(); const projects = useProjects(); // Stop rebuilding the streaming thread list while Thread covers Home. diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index dac883341cc10..4ed447eb69f23 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,3 +1,4 @@ +import { useContentTopInset } from "../../components/ui/useBarInset"; import { usePaneRole } from "../../components/ui/Pane"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { useActiveNewTask } from "../../state/active-new-task"; @@ -262,6 +263,7 @@ export function HomeScreen(props: HomeScreenProps) { const openSwipeableRef = useRef(null); const homeScrollGesture = useNativeGesture(); const insets = useSafeAreaInsets(); + const contentTopInset = useContentTopInset(); const emptyTopInset = useWorkspaceListEmptyTopInset(); const { fabClearance } = useAndroidControlSizing(); const screenMetrics = useNativeLayoutMetrics(); @@ -326,7 +328,7 @@ export function HomeScreen(props: HomeScreenProps) { useSearchScrollRestoration(searchMode, () => { if (restoredOffset === undefined) return; void listRef.current?.scrollToOffset({ - offset: restoredOffset - (Platform.OS === "ios" ? insets.top : 0), + offset: restoredOffset - contentTopInset, animated: false, }); }); @@ -347,7 +349,7 @@ export function HomeScreen(props: HomeScreenProps) { if (workspace) workspace.recordScrollOffset( "threads", - Math.max(0, event.nativeEvent.contentOffset.y + (Platform.OS === "ios" ? insets.top : 0)), + Math.max(0, event.nativeEvent.contentOffset.y + contentTopInset), ); clearTimeout(activationTimerRef.current); activationTimerRef.current = setTimeout( @@ -798,7 +800,7 @@ export function HomeScreen(props: HomeScreenProps) { onTouchStart={(event) => trackListTouches(event, true)} onTouchEnd={(event) => trackListTouches(event, false)} onTouchCancel={(event) => trackListTouches(event, false)} - renderScrollComponent={renderHomeScrollView} + renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined} data={threadListV2Items} renderItem={renderV2Item} keyExtractor={v2KeyExtractor} @@ -829,17 +831,11 @@ export function HomeScreen(props: HomeScreenProps) { } ListEmptyComponent={v2ListEmpty} style={{ flex: 1 }} - automaticallyAdjustsScrollIndicatorInsets - // UIKit owns the bar's inset, so a minimizing bar and a resized column move - // the list in the same frame as the bar. - contentInsetAdjustmentBehavior="automatic" - contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} - // RN scrollTo clamps against raw insets and ignores the automatic bar inset. - scrollToOverflowEnabled + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior="never" + contentInset={Platform.OS === "ios" ? { top: contentTopInset } : undefined} initialScrollOffset={ - initialScrollOffset > 0 - ? initialScrollOffset - (Platform.OS === "ios" ? insets.top : 0) - : undefined + initialScrollOffset > 0 ? initialScrollOffset - contentTopInset : undefined } showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" @@ -853,7 +849,7 @@ export function HomeScreen(props: HomeScreenProps) { (contentSideInsets?.right ?? 0) + (sidebarSurface ? 8 : 0) + androidListGutter, paddingBottom: Platform.OS === "ios" - ? iosBottomClearance - (columnMetrics?.safeArea.bottom ?? insets.bottom) + ? iosBottomClearance : (workspace?.bottomClearance ?? Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88)), diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index a6c48086f2c85..6f2e8a7a9020f 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -391,10 +391,10 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { } }, [commitPrimaryAction, content, fullSwipeAction, onDelete]); const handleRelease = useCallback( - (translation: SharedValue) => { + (translation: SharedValue, canceled: boolean) => { "worklet"; if (dismissing.value) return true; - if (-translation.value >= fullSwipeThreshold) { + if (!canceled && -translation.value >= fullSwipeThreshold) { runOnJS(commitFullSwipe)(); return true; } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index bf8b3ad9b08dc..92d6be2d3309e 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -3,20 +3,24 @@ import { WorkspacePresentationHostContext, } from "./workspace-presentation-context"; import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider"; -import { useSoftKeyboardShowing } from "../keyboard/useSoftKeyboardShowing"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; -import { AdaptiveWorkspaceContext, useAdaptiveWorkspaceLayout } from "./adaptive-workspace-context"; +import { + AdaptiveWorkspaceContext, + WorkspacePaneStateContext, + useAdaptiveWorkspaceLayout, + useWorkspacePaneState, + type AdaptiveWorkspaceActions, +} from "./adaptive-workspace-context"; import { threadSelectionDeferral } from "./deferred-navigation"; import { uuidv4 } from "../../lib/uuid"; -import { useToolbarLayout } from "../../components/ui/useToolbarLayout"; import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; import { use, - useDeferredValue, useEffect, useEffectEvent, + useLayoutEffect, useMemo, useRef, useState, @@ -30,11 +34,6 @@ import { useWindowDimensions, View, } from "react-native"; -import { - KeyboardController, - useKeyboardContext, - useKeyboardState, -} from "react-native-keyboard-controller"; import Animated, { runOnJS, useAnimatedStyle, @@ -118,7 +117,7 @@ import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout"; import { WorkspaceThreadSidebar } from "./workspace/WorkspaceThreadSidebar"; -export { useAdaptiveWorkspaceLayout }; +export { useAdaptiveWorkspaceLayout, useWorkspacePaneState }; /** * Shows these panes in the workspace's inspector while this screen is focused. Register on focus @@ -191,6 +190,34 @@ export function Inspector(props: { return null; } +/** + * Actions whose identities never change, so a pane toggle doesn't re-render every screen that + * holds one. Each call runs the latest render's handler. + */ +function useStableActions(actions: Required): AdaptiveWorkspaceActions { + const latest = useRef(actions); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- points the stable actions at the handlers this commit rendered + useLayoutEffect(() => { + latest.current = actions; + }); + const [stable] = useState((): AdaptiveWorkspaceActions => ({ + startNewTask: () => latest.current.startNewTask(), + selectDetail: (detail, sheet) => latest.current.selectDetail(detail, sheet), + registerWorkspaceInspector: (content, routeKey, revealKey) => + latest.current.registerWorkspaceInspector(content, routeKey, revealKey), + setPrimarySidebarSearchQuery: (query) => latest.current.setPrimarySidebarSearchQuery(query), + showInspector: () => latest.current.showInspector(), + toggleAuxiliaryPane: () => latest.current.toggleAuxiliaryPane(), + togglePrimarySidebar: () => latest.current.togglePrimarySidebar(), + revealPrimarySidebar: () => latest.current.revealPrimarySidebar(), + setPrimarySidebarVisible: (visible) => latest.current.setPrimarySidebarVisible(visible), + openWorkspaceTab: (tab, options) => latest.current.openWorkspaceTab(tab, options), + setAuxiliaryPaneWidth: (width) => latest.current.setAuxiliaryPaneWidth(width), + setNativeInspectorWidth: (width) => latest.current.setNativeInspectorWidth(width), + })); + return stable; +} + export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string; @@ -245,13 +272,10 @@ function AdaptiveWorkspaceLayoutContent( const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); const [nativeInspectorWidthFraction, setNativeInspectorWidthFraction] = useState(0.4); + const horizontalSafeArea = + (nativeMetrics?.safeArea.left ?? 0) + (nativeMetrics?.safeArea.right ?? 0); const inspectorWidthFor = (fraction: number) => - nativeInspectorColumnWidth({ - viewportWidth, - horizontalSafeArea: - (nativeMetrics?.safeArea.left ?? 0) + (nativeMetrics?.safeArea.right ?? 0), - fraction, - }); + nativeInspectorColumnWidth({ viewportWidth, horizontalSafeArea, fraction }); const nativeInspectorWidth = inspectorWidthFor(nativeInspectorWidthFraction); const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState( null, @@ -295,37 +319,11 @@ function AdaptiveWorkspaceLayoutContent( nativeWorkspace, usesSplitView: baseLayout.usesSplitView, }); - const [composerEditing, setComposerEditing] = useState(false); - const softKeyboardVisible = useSoftKeyboardShowing(); - // Keyed on the soft keyboard, not focus: a hardware keyboard leaves room for the sidebar, and - // Back closes the keyboard while the editor keeps focus. The sidebar returns as the keyboard - // starts hiding, not after. Deferred so the composer commits its keyboard render first; - // relaying out in the same commit held it behind the keyboard. - const composerKeyboardActive = useDeferredValue( - Platform.OS === "android" && baseLayout.usesSplitView && composerEditing && softKeyboardVisible, - ); - const sidebarWithoutKeyboard = - !workspacePage && (primarySidebarPinned || preferredSidebarVisible); - const showPrimarySidebar = - sidebarWithoutKeyboard && (primarySidebarPinned || !composerKeyboardActive); + const showPrimarySidebar = !workspacePage && (primarySidebarPinned || preferredSidebarVisible); // Medium windows float the sidebar over a page; beside a list it keeps its room. const sidebarFloats = baseLayout.variant === "medium" && (nativeWorkspace || (!primarySidebarPinned && !workspacePage)); - // While the composer edits, the sidebar's width follows the keyboard's own progress, so it - // leaves and returns in step with the keyboard instead of on a timer of its own. - const keyboardHoldsSidebar = - Platform.OS === "android" && - baseLayout.usesSplitView && - !workspacePage && - !primarySidebarPinned && - !sidebarFloats && - (composerEditing || composerKeyboardActive); - const keyboardProgress = useKeyboardContext().reanimated.progress; - // From the moment the keyboard starts showing until it has fully hidden. The detail pane keeps - // the sidebar's room for that whole span, so on the way back the sidebar pushes the wide pane - // aside instead of arriving beside one that already shrank. - const keyboardOnScreen = useKeyboardState((state) => state.isVisible); const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView && !floatingTabsEnabled; const enabledTabs = useEnabledNavigationTabs(); @@ -408,6 +406,7 @@ function AdaptiveWorkspaceLayoutContent( deriveWorkspacePaneLayout({ layout, viewportWidth: width, + horizontalSafeArea, nativeWorkspace, primarySidebarFloats: sidebarFloats, primarySidebarPreferredVisible: showPrimarySidebar, @@ -419,6 +418,7 @@ function AdaptiveWorkspaceLayoutContent( }), [ nativeInspectorWidth, + horizontalSafeArea, fileInspectorPreferredVisible, fileInspectorPreferredWidth, inspectorActive, @@ -430,37 +430,6 @@ function AdaptiveWorkspaceLayoutContent( workspacePage, ], ); - // Whether the sidebar is open once the keyboard is gone; the keyboard's progress scales it. - const sidebarRestsOpen = useMemo( - () => - showPrimarySidebar === sidebarWithoutKeyboard - ? panes.primarySidebarVisible - : deriveWorkspacePaneLayout({ - layout, - viewportWidth: width, - nativeWorkspace, - primarySidebarFloats: sidebarFloats, - primarySidebarPreferredVisible: sidebarWithoutKeyboard, - auxiliaryPanePreferredVisible: - !workspacePage && inspectorActive && fileInspectorPreferredVisible, - auxiliaryPanePreferredWidth: fileInspectorPreferredWidth ?? undefined, - nativeInspectorWidth, - }).primarySidebarVisible, - [ - nativeInspectorWidth, - fileInspectorPreferredVisible, - fileInspectorPreferredWidth, - inspectorActive, - layout, - nativeWorkspace, - panes.primarySidebarVisible, - showPrimarySidebar, - sidebarFloats, - sidebarWithoutKeyboard, - width, - workspacePage, - ], - ); // Routes still on the stack remember their reveal, so going back to one keeps the user's choice. const revealedInspectors = useRef>(new Map()); const workspaceInspectorOwner = useRef(null); @@ -509,12 +478,6 @@ function AdaptiveWorkspaceLayoutContent( if (primarySidebarPinned) { return; } - // A sidebar the keyboard set aside returns with the keyboard; the user's preference stays - // intact. One the user had hidden still opens. - if (composerKeyboardActive) { - void KeyboardController.dismiss(); - if (preferredSidebarVisible) return; - } if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); setPrimarySidebarPreferredVisible(true); @@ -523,7 +486,6 @@ function AdaptiveWorkspaceLayoutContent( setPrimarySidebarVisible(!preferredSidebarVisible); }; const revealPrimarySidebar = () => { - if (composerKeyboardActive) void KeyboardController.dismiss(); if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); } @@ -670,8 +632,6 @@ function AdaptiveWorkspaceLayoutContent( const contentEndGap = Platform.OS === "android" && layout.usesSplitView ? 4 : 0; // Android's split is one strip: a sidebar slot of fixed width, then the detail pane. The // sidebar never resizes; both slide by one offset, so together they always fill the width. - // The pane spans the sidebar's room too unless the sidebar has finished arriving: a sidebar on - // its way in pushes the wide pane past the edge, and one leaving uncovers it. const stripSidebar = Platform.OS === "android" && layout.usesSplitView && @@ -679,27 +639,9 @@ function AdaptiveWorkspaceLayoutContent( !workspacePage && !sidebarFloats; const sidebarSlotWidth = (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0); - const sidebarTargetOpen = keyboardHoldsSidebar ? sidebarRestsOpen : panes.primarySidebarVisible; - const [sidebarMotion, setSidebarMotion] = useState({ - strip: stripSidebar, - settledOpen: sidebarTargetOpen, - }); - if (sidebarMotion.strip !== stripSidebar) { - // Entering or leaving the strip (a workspace page, a fold) snaps the sidebar into place. - setSidebarMotion({ strip: stripSidebar, settledOpen: sidebarTargetOpen }); - } else if (sidebarMotion.settledOpen && !sidebarTargetOpen) { - setSidebarMotion({ strip: stripSidebar, settledOpen: false }); - } - const sidebarSettledOpen = - sidebarMotion.strip === stripSidebar ? sidebarMotion.settledOpen : sidebarTargetOpen; - const markSidebarSettled = (open: boolean) => - setSidebarMotion((current) => - current.settledOpen === open ? current : { ...current, settledOpen: open }, - ); - // The keyboard covers the sidebar's share from the moment it starts showing until it is gone. - const contentSpansSidebar = - stripSidebar && - (!sidebarSettledOpen || !sidebarTargetOpen || (keyboardHoldsSidebar && keyboardOnScreen)); + // The pane takes its new width in the commit that starts the sidebar moving, so it rewraps as + // the strip starts to slide rather than after it lands. + const contentSpansSidebar = stripSidebar && !panes.primarySidebarVisible; const contentTargetWidth = !layout.usesSplitView ? null : Math.max( @@ -810,16 +752,12 @@ function AdaptiveWorkspaceLayoutContent( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, ); const renderedRailWidth = useSharedValue(navigationRailWidth); - const keyboardHold = useSharedValue(keyboardHoldsSidebar); const leavingWorkspacePage = useRef(false); + // The slide starts after the commit that lays the pane out, not in the press handler: Reanimated + // commits the shadow tree every frame, and that starves a large React commit until it stops. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- animates the Reanimated rail and sidebar width shared values toward the layout's targets useEffect(() => { if (nativeWorkspace) return; - // Released mid-keyboard (the composer blurred), the timing starts where the sidebar is drawn. - if (keyboardHold.get() && !keyboardHoldsSidebar) { - renderedSidebarWidth.set(renderedSidebarWidth.get() * (1 - keyboardProgress.get())); - } - keyboardHold.set(keyboardHoldsSidebar); renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING)); // A workspace page hides the sidebar through React instead, in the same commit that mounts // the page. Animating it here would hide it a few frames before the page appears. @@ -829,15 +767,15 @@ function AdaptiveWorkspaceLayoutContent( } const snap = leavingWorkspacePage.current; leavingWorkspacePage.current = false; - const targetWidth = (keyboardHoldsSidebar ? sidebarRestsOpen : panes.primarySidebarVisible) + const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0; // The content beside it already took its final width when the page closed. renderedSidebarWidth.set( withTiming(targetWidth, snap ? { duration: 0 } : WORKSPACE_PANE_TIMING, (finished) => { - if (finished && Platform.OS === "android") { + // Only a hinge resize widens the list's span; a toggle has nothing to settle. + if (finished && Platform.OS === "android" && listPaneFloor !== listPaneCeiling) { runOnJS(settleListPane)(listPaneRevision); - runOnJS(markSidebarSettled)(targetWidth > 0); } }), ); @@ -847,14 +785,11 @@ function AdaptiveWorkspaceLayoutContent( layout.listPaneWidth, layout.listPaneGap, panes.primarySidebarVisible, - keyboardHoldsSidebar, - keyboardHold, - keyboardProgress, - sidebarRestsOpen, renderedSidebarWidth, renderedRailWidth, settleListPane, - markSidebarSettled, + listPaneFloor, + listPaneCeiling, listPaneRevision, workspacePage, ]); @@ -885,45 +820,29 @@ function AdaptiveWorkspaceLayoutContent( // Showing or hiding the sidebar clips a fixed-width list. A hinge resize instead tracks the // animated edge, so the header actions slide with it rather than jumping or being covered. const listPaneGap = layout.listPaneGap ?? 0; - const keyboardSidebarRest = sidebarRestsOpen - ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) - : 0; - const drawnSidebarWidth = useDerivedValue( - () => renderedSidebarWidth.value * (keyboardHold.value ? 1 - keyboardProgress.value : 1), - ); const listPaneAnimatedStyle = useAnimatedStyle(() => ({ width: Math.min( listPaneCeiling, Math.max( listPaneFloor, - (stripSidebar ? sidebarSlotWidth : drawnSidebarWidth.value) - listPaneGap, + (stripSidebar ? sidebarSlotWidth : renderedSidebarWidth.value) - listPaneGap, ), ), })); // How far the strip has slid: 0 with the sidebar shown, minus its width with it gone. const stripOffset = useDerivedValue(() => - stripSidebar ? drawnSidebarWidth.value - sidebarSlotWidth : 0, + stripSidebar ? renderedSidebarWidth.value - sidebarSlotWidth : 0, ); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ // Fading a UIKit glass ancestor can leave remounted header effects missing. opacity: stripSidebar || Platform.OS === "ios" || floatingTabsEnabled ? 1 - : Math.min(1, drawnSidebarWidth.value / 80), - width: stripSidebar ? sidebarSlotWidth : drawnSidebarWidth.value, + : Math.min(1, renderedSidebarWidth.value / 80), + width: stripSidebar ? sidebarSlotWidth : renderedSidebarWidth.value, flexShrink: stripSidebar ? 0 : 1, transform: [{ translateX: stripOffset.value }], })); - // The content keeps the width React laid it out at and rides the sidebar's edge, so a keyboard - // transition relays nothing out per frame, the way UIKit animates the iOS split view. - // Until React commits the wider layout, the detail card continues past its right edge, so the - // strip the narrower column leaves shows the card instead of the row behind it. - const contentOverhangStyle = useAnimatedStyle(() => ({ - opacity: keyboardHold.value && keyboardProgress.value > 0 ? 1 : 0, - })); - const threadHeader = useToolbarLayout(false, composerKeyboardActive); - const threadHeaderHeight = - threadHeader.paddingTop + threadHeader.height + threadHeader.paddingBottom; // Clip the navigator at its settled width so native headers and markdown reflow only once per pane toggle. const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); @@ -932,7 +851,7 @@ function AdaptiveWorkspaceLayoutContent( 0, viewportWidth - (sidebarFloats ? 0 : renderedRailWidth.value) - - (workspacePage || sidebarFloats ? 0 : drawnSidebarWidth.value) - + (workspacePage || sidebarFloats ? 0 : renderedSidebarWidth.value) - renderedInspectorWidth.value, ), ); @@ -987,32 +906,36 @@ function AdaptiveWorkspaceLayoutContent( [sidebarInLandscape, sidebarInPortrait, sidebarOverRotates], ); - const contextValue = { - layout, - panes, - fileInspector, - primarySidebarSearchQuery, + const actions = useStableActions({ startNewTask: handleStartNewTask, selectDetail: handleSelectDetail, registerWorkspaceInspector, setPrimarySidebarSearchQuery, showInspector, toggleAuxiliaryPane, - sidebarToggleable: layout.usesSplitView && !primarySidebarPinned && !workspacePage, togglePrimarySidebar, revealPrimarySidebar, setPrimarySidebarVisible, openWorkspaceTab, setAuxiliaryPaneWidth: setFileInspectorPreferredWidth, - nativeInspectorWidth, setNativeInspectorWidth: (width: number) => setNativeInspectorWidthFraction(inspectorWidthFor(width / viewportWidth) / viewportWidth), - composerKeyboardActive, - keyboardHoldsSidebar, - setComposerEditing, - sidebarByOrientation, - workspacePageWidth, - }; + }); + const contextValue = useMemo( + () => ({ ...actions, layout, nativeInspectorWidth, workspacePageWidth }), + [actions, layout, nativeInspectorWidth, workspacePageWidth], + ); + const sidebarToggleable = layout.usesSplitView && !primarySidebarPinned && !workspacePage; + const paneState = useMemo( + () => ({ + panes, + fileInspector, + primarySidebarSearchQuery, + sidebarToggleable, + sidebarByOrientation, + }), + [panes, fileInspector, primarySidebarSearchQuery, sidebarToggleable, sidebarByOrientation], + ); const inspectorSheet = ( - + + + ); @@ -1173,35 +1098,6 @@ function AdaptiveWorkspaceLayoutContent( - {keyboardHoldsSidebar && contentTargetWidth !== null ? ( - <> - - - - ) : null} {sidebarFloats ? ( - - - {usesPresentationHost ? ( - - {props.children} - - ) : ( - renderColumns(props.children) - )} - - + + + + {usesPresentationHost ? ( + + {props.children} + + ) : ( + renderColumns(props.children) + )} + + + ); } diff --git a/apps/mobile/src/features/layout/adaptive-workspace-context.ts b/apps/mobile/src/features/layout/adaptive-workspace-context.ts index d82e6202c633b..e4ef355412af5 100644 --- a/apps/mobile/src/features/layout/adaptive-workspace-context.ts +++ b/apps/mobile/src/features/layout/adaptive-workspace-context.ts @@ -11,11 +11,8 @@ import { type WorkspacePaneLayout, } from "../../lib/layout"; -interface AdaptiveWorkspaceContextValue { - readonly layout: Layout; - readonly panes: WorkspacePaneLayout; - readonly fileInspector: FileInspectorPaneLayout; - readonly primarySidebarSearchQuery: string; +/** The workspace's actions; their identities never change. */ +export interface AdaptiveWorkspaceActions { readonly startNewTask: () => void; /** Use `useSelectWorkspaceDetail`, which also works outside the layout. */ readonly selectDetail?: (detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => void; @@ -28,11 +25,6 @@ interface AdaptiveWorkspaceContextValue { /** Opens the inspector: its column where the window has one, otherwise a sheet. */ readonly showInspector: () => void; readonly toggleAuxiliaryPane: () => void; - /** - * Whether the content pane offers a sidebar button: false without columns, on a list that keeps - * its sidebar, and on a workspace page (Android Settings, Usage) that covers it. - */ - readonly sidebarToggleable: boolean; readonly togglePrimarySidebar: () => void; /** Shows the sidebar, setting aside the keyboard or an inspector that took its room. */ readonly revealPrimarySidebar: () => void; @@ -43,24 +35,32 @@ interface AdaptiveWorkspaceContextValue { options?: { readonly columns?: boolean }, ) => void; readonly setAuxiliaryPaneWidth: (width: number) => void; - readonly nativeInspectorWidth: number; readonly setNativeInspectorWidth: (width: number) => void; +} + +/** Changes only with the window (size, fold, hinge), never with a pane toggle or the keyboard. */ +interface AdaptiveWorkspaceLayoutValue extends AdaptiveWorkspaceActions { + readonly layout: Layout; + readonly nativeInspectorWidth: number; + /** Android split: the width a workspace page (Usage) opens at; null without columns. */ + readonly workspacePageWidth?: number | null; +} + +/** Changes as panes toggle and the keyboard comes and goes; read it only where that shows. */ +interface WorkspacePaneStateValue { + readonly panes: WorkspacePaneLayout; + readonly fileInspector: FileInspectorPaneLayout; + readonly primarySidebarSearchQuery: string; /** - * Android split view while the thread composer has the soft keyboard: the - * sidebar and rail step aside and the thread header drops to one line. + * Whether the content pane offers a sidebar button: false without columns, on a list that keeps + * its sidebar, and on a workspace page (Android Settings, Usage) that covers it. */ - readonly composerKeyboardActive: boolean; + readonly sidebarToggleable: boolean; /** * Whether the sidebar shows in each orientation, where it floats over the content in portrait * (the unfolded Duo). The native split switches between them as a rotation starts. */ readonly sidebarByOrientation: { readonly landscape: boolean; readonly portrait: boolean } | null; - /** Android split: the composer's keyboard slides the sidebar, from focus until it lets go. */ - readonly keyboardHoldsSidebar?: boolean; - /** Android split: the width a workspace page (Usage) opens at; null without columns. */ - readonly workspacePageWidth?: number | null; - /** The thread composer reports its focus so the layout can make room for typing. */ - readonly setComposerEditing: (editing: boolean) => void; } const compactLayout = deriveLayout({ width: 0, height: 0 }); @@ -74,17 +74,13 @@ const compactFileInspector = deriveFileInspectorPaneLayout({ layout: compactLayout, viewportWidth: 0, }); -export const AdaptiveWorkspaceContext = createContext({ +export const AdaptiveWorkspaceContext = createContext({ layout: compactLayout, - panes: compactPanes, - fileInspector: compactFileInspector, - primarySidebarSearchQuery: "", startNewTask: () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, showInspector: () => undefined, toggleAuxiliaryPane: () => undefined, - sidebarToggleable: false, togglePrimarySidebar: () => undefined, revealPrimarySidebar: () => undefined, setPrimarySidebarVisible: () => undefined, @@ -92,11 +88,21 @@ export const AdaptiveWorkspaceContext = createContext undefined, nativeInspectorWidth: 0, setNativeInspectorWidth: () => undefined, - composerKeyboardActive: false, +}); +export const WorkspacePaneStateContext = createContext({ + panes: compactPanes, + fileInspector: compactFileInspector, + primarySidebarSearchQuery: "", + sidebarToggleable: false, sidebarByOrientation: null, - setComposerEditing: () => undefined, }); -export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { +/** The window's layout and the workspace's actions. Toggling a pane doesn't re-render readers. */ +export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceLayoutValue { return use(AdaptiveWorkspaceContext); } + +/** Which panes show, and the keyboard's hold on them. Re-renders on every pane toggle. */ +export function useWorkspacePaneState(): WorkspacePaneStateValue { + return use(WorkspacePaneStateContext); +} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 14ffb11abf22b..a0e677b6f8ded 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -46,7 +46,7 @@ import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { setNativelyPoppingWorkspaceRoute } from "./workspace/workspace-detail-selection"; import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; -import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; +import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "./AdaptiveWorkspaceLayout"; import { WorkspacePaneDivider } from "./workspace-pane-divider"; import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail"; import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios"; @@ -205,15 +205,14 @@ function WorkspaceColumns( ) { const { layout, - panes, setPrimarySidebarVisible, toggleAuxiliaryPane, startNewTask, openWorkspaceTab, - sidebarByOrientation, nativeInspectorWidth, setNativeInspectorWidth, } = useAdaptiveWorkspaceLayout(); + const { panes, sidebarByOrientation } = useWorkspacePaneState(); const deviceType = useDeviceType(); const inspectorWidth = nativeInspectorWidth; const resizeStartWidth = useRef(inspectorWidth); diff --git a/apps/mobile/src/features/layout/inspector-host.tsx b/apps/mobile/src/features/layout/inspector-host.tsx index 4a24eafb9b6da..368b44f64988b 100644 --- a/apps/mobile/src/features/layout/inspector-host.tsx +++ b/apps/mobile/src/features/layout/inspector-host.tsx @@ -23,7 +23,14 @@ const PANE_HEADER_OPTIONS = { ...GLASS_HEADER_OPTIONS, headerShown: false }; function PaneHeader(props: { readonly store: InspectorBarStore }) { const options = useSyncExternalStore(props.store.subscribe, props.store.getSnapshot); - return ; + // Its own search identity, so opening the inspector doesn't morph the sidebar's search into it. + return ( + + ); } function PaneScreenContent(props: { diff --git a/apps/mobile/src/features/layout/useSidebarToggle.ts b/apps/mobile/src/features/layout/useSidebarToggle.ts index 5b0beed08a434..df5e93a50c192 100644 --- a/apps/mobile/src/features/layout/useSidebarToggle.ts +++ b/apps/mobile/src/features/layout/useSidebarToggle.ts @@ -1,10 +1,11 @@ import { usePaneRole } from "../../components/ui/Pane"; -import { useAdaptiveWorkspaceLayout } from "./adaptive-workspace-context"; +import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "./adaptive-workspace-context"; /** The content pane's sidebar button, or null where there's no sidebar beside it to toggle. */ export function useSidebarToggle() { const role = usePaneRole(); - const { layout, panes, sidebarToggleable, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const { layout, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const { panes, sidebarToggleable } = useWorkspacePaneState(); if (role !== "content" || !sidebarToggleable) return null; const maximizes = panes.primarySidebarVisible && layout.variant !== "medium"; return { diff --git a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx index 246a0c9d7cb74..382336367e2fd 100644 --- a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx +++ b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx @@ -81,7 +81,7 @@ export function AndroidWorkspaceNavigation(props: { const toolbar = useToolbarLayout(); const connectionState = useWorkspaceConnectionState(); const keyboardVisible = useSoftKeyboardShowing(); - const { layout, keyboardHoldsSidebar } = useAdaptiveWorkspaceLayout(); + const { layout } = useAdaptiveWorkspaceLayout(); const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const { scale, fabSize } = useAndroidControlSizing(); const window = useWindowDimensions(); @@ -102,8 +102,7 @@ export function AndroidWorkspaceNavigation(props: { (props.paneWidth ?? window.width) - (Math.max(insets.left, FLOATING_TABS.edge) - FLOATING_TABS.edge); const metrics = floatingTabsMetrics(tabs.length, scale, fabSize, barWidth, props.fillWidth); - // The composer's keyboard slides the sidebar away with the bar on it; other keyboards don't. - if (!rail && keyboardVisible && !keyboardHoldsSidebar) return null; + if (!rail && keyboardVisible) return null; return ( void }) { - const workspace = useWorkspaceTabs()!; - return ( - - - - {(["threads", "pullRequests"] as const).map((tab) => { - const selected = workspace.state.selectedTab === tab; - return ( - { - if (selected) return; - Keyboard.dismiss(); - workspace.dispatch({ type: "select", tab }); - }} - className={ - selected - ? "flex-1 items-center justify-center rounded-full bg-subtle-strong" - : "flex-1 items-center justify-center rounded-full" - } - > - - - ); - })} - - - - - - - - - ); -} diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceListEmptyDetail.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceListEmptyDetail.tsx index fb1d86ad127f6..f95491281fe3f 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceListEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceListEmptyDetail.tsx @@ -3,7 +3,7 @@ import { Platform } from "react-native"; import { AppBar } from "../../../components/ui/AppBar"; import { Toolbar } from "../../../components/ui/Toolbar"; import { NativeStackScreenOptions } from "../../../native/StackHeader"; -import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; +import { useWorkspacePaneState } from "../adaptive-workspace-context"; import { WorkspaceEmptyDetail } from "../WorkspaceEmptyDetail"; /** @@ -15,7 +15,7 @@ export function WorkspaceListEmptyDetail(props: { readonly onAddConnection: () => void; readonly onStartNewTask: () => void; }) { - const { panes } = useAdaptiveWorkspaceLayout(); + const { panes } = useWorkspacePaneState(); return ( <>
); } -export function AppSidebarLayout({ children }: { children: ReactNode }) { +export function AppSidebarLayout({ + children, + showSidebar = true, +}: { + children: ReactNode; + showSidebar?: boolean; +}) { const sidebarArea = workspaceAreaProps("sidebar", 0, "Threads"); const [sidebarRouteReveal] = useState(() => ({ observedKey: null, @@ -450,31 +460,35 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { storageKey="chat_thread_sidebar_open" style={sidebarProviderStyle} > - - - nextWidth <= currentWidth || - wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, - storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, - onResize: setSidebarWidth, - }} - style={windowControlsInsetStyle} - > - - - + {showSidebar ? ( + <> + + + nextWidth <= currentWidth || + wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, + storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + onResize: setSidebarWidth, + }} + style={windowControlsInsetStyle} + > + + + + + ) : null} {children} - + {isElectron ? : null} diff --git a/apps/web/src/components/BranchPicker.tsx b/apps/web/src/components/BranchPicker.tsx index cfa082368a974..f14d880e9b7ae 100644 --- a/apps/web/src/components/BranchPicker.tsx +++ b/apps/web/src/components/BranchPicker.tsx @@ -1,11 +1,10 @@ +import { useLegendListRef } from "~/hooks/useLegendListRef"; import type { VcsRef } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { useId, useRef, useState, type ComponentProps, type ReactNode } from "react"; import { MiddleTruncate } from "./ui/middle-truncate"; -import { observeResize } from "../lib/observeResize"; import { cn } from "../lib/utils"; -import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; import { RefreshIcon } from "./ui/refresh-icon"; import { Switch } from "./ui/switch"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; @@ -59,10 +58,7 @@ export function BranchPicker({ }) { const highlightedValueRef = useRef(null); const startFromOriginSwitchId = useId(); - const branchListScrollElementRef = useRef(null); - const previousBranchListScrollTopRef = useRef(null); const handleOpenChange = (nextOpen: boolean) => { - previousBranchListScrollTopRef.current = null; if (!nextOpen) highlightedValueRef.current = null; onOpenChange(nextOpen); }; @@ -75,59 +71,14 @@ export function BranchPicker({ onLoadNext(); }; - const maybeFetchNextBranchPage = () => { - const scrollElement = branchListScrollElementRef.current; - if (!scrollElement) { - return; - } - - const previousScrollTop = previousBranchListScrollTopRef.current; - previousBranchListScrollTopRef.current = scrollElement.scrollTop; - if ( - !open || - !hasNextPage || - isFetchingNextPage || - !shouldLoadNextBranchPageAfterScroll({ - previousScrollTop, - scrollTop: scrollElement.scrollTop, - scrollHeight: scrollElement.scrollHeight, - clientHeight: scrollElement.clientHeight, - }) - ) { - return; - } - - fetchNextBranchPage(); - }; - const branchListRef = useRef(null); const updateBranchListScrollFades = () => { - const scrollElement = branchListRef.current?.getScrollableNode?.(); - if (!(scrollElement instanceof HTMLElement)) { - return; - } - branchListScrollElementRef.current = scrollElement; - const maxScrollOffset = Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight); - setShowTopBranchScrollFade(scrollElement.scrollTop > 1); - setShowBottomBranchScrollFade(maxScrollOffset - scrollElement.scrollTop > 1); - }; - - // The popup mounts the list when it opens. Its content settles over a few - // frames as rows measure, so the fades follow the scroller and its content. - const attachBranchList = (list: LegendListRef | null) => { - branchListRef.current = list; - const scrollElement = list?.getScrollableNode?.(); - if (!(scrollElement instanceof HTMLElement)) return; - const content = scrollElement.firstElementChild; - const stopObserving = observeResize( - content ? [scrollElement, content] : scrollElement, - updateBranchListScrollFades, - ); - return () => { - stopObserving(); - branchListRef.current = null; - }; + const metrics = branchListRef.current?.getState(); + if (!metrics) return; + setShowTopBranchScrollFade(!metrics.isAtStart); + setShowBottomBranchScrollFade(!metrics.isAtEnd); }; + const branchScrollRef = useLegendListRef(branchListRef, updateBranchListScrollFades); return ( - ref={attachBranchList} + ref={branchScrollRef} data={filteredItems} + initialScrollOffset={0} keyExtractor={(item) => item} {...(getItemType ? { getItemType } : {})} renderItem={({ item, index }) => renderItem(item, index)} estimatedItemSize={28} drawDistance={336} - onLayout={() => { - updateBranchListScrollFades(); - previousBranchListScrollTopRef.current = - branchListScrollElementRef.current?.scrollTop ?? null; - }} - onScroll={() => { - updateBranchListScrollFades(); - maybeFetchNextBranchPage(); - }} + onScroll={updateBranchListScrollFades} + onLayout={updateBranchListScrollFades} + onReady={updateBranchListScrollFades} + onEndReached={fetchNextBranchPage} + onEndReachedThreshold={0.5} className={cn( "min-h-0 max-h-56 scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 py-2", getVirtualizedScrollFadeClassName({ diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 903a20066b39e..8b2222c4f74a6 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -101,16 +101,23 @@ describe("deriveLocalBranchNameFromRemoteRef", () => { }); describe("dedupeRemoteBranchesWithLocalMatches", () => { - it("hides remote refs when the matching local ref exists", () => { + it.each([ + ["feature/demo", "origin/feature/demo", true], + ["feature/demo", "feature/demo", false], + ["demo", "demo", true], + ["origin/", "origin/", true], + ["/demo", "/demo", true], + ["", "", false], + ])("filters origin ref %s / %s (hidden: %s)", (localName, remoteName, hidden) => { const input: VcsRef[] = [ { - name: "feature/demo", + name: localName, current: false, isDefault: false, worktreePath: null, }, { - name: "origin/feature/demo", + name: remoteName, isRemote: true, remoteName: "origin", current: false, @@ -127,10 +134,12 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ - "feature/demo", - "origin/feature/remote-only", - ]); + const result = dedupeRemoteBranchesWithLocalMatches(input); + const expected = hidden ? [input[0], input[2]] : input; + expect(result).toEqual(expected); + for (const [index, ref] of result.entries()) { + expect(ref).toBe(expected[index]); + } }); it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => { diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index dac98cd1a5e6d..3cc21392cea8d 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -39,9 +39,25 @@ vi.mock(import("../state/server"), async (original) => { const actual = await original(); const { Atom } = await import("effect/reactivity"); const noConfig = Atom.make(null); + const modelsConfig = Atom.make({ + availableEditors: [], + settings: { providerInstances: {} }, + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + models: [{ slug: "gpt-6.1", name: "GPT-6.1", isCustom: false, capabilities: {} }], + }, + ], + }); return { ...actual, - serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => noConfig) as never }, + serverEnvironment: { + ...actual.serverEnvironment, + configValueAtom: ((environmentId: string) => + environmentId === "models" ? modelsConfig : noConfig) as never, + }, }; }); vi.mock(import("../state/session"), async (original) => { @@ -324,6 +340,25 @@ describe("ChatMarkdown streaming", () => { expect(checkboxes()[1]).toBe(original); expect(checkboxes()[1]!.checked).toBe(false); }); + + it.each([ + ["read-only", undefined], + ["editable", () => {}], + ])( + "unchecks the remaining %s task when the checked row above it is removed", + async (_, onTaskListChange) => { + setup(); + const message = (text: string) => ( + + ); + await using screen = await render(message("- [x] Completed item\n- [ ] Pending item")); + await screen.rerender(message("- [ ] Pending item")); + const checkboxes = + screen.container.querySelectorAll('input[type="checkbox"]'); + expect(checkboxes).toHaveLength(1); + expect(checkboxes[0]!.checked).toBe(false); + }, + ); }); describe("ChatMarkdown workspace file opens", () => { @@ -396,6 +431,19 @@ describe("ChatMarkdown skill chips", () => { expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "/skills/review/SKILL.md"); }); + + it("names a mentioned model as its provider does, without a thread", async () => { + setup(); + await using screen = await renderText("Ask (model:codex/gpt-6.1), not model:openai/gpt-4o.", { + environmentId: EnvironmentId.make("models"), + }); + + await expect + .element(screen.getByRole("button", { name: "Model GPT-6.1. Show details" })) + .toBeVisible(); + expect(buttonsLabelled(screen.container, "Model gpt-4o. Show details")).toEqual([]); + expect(screen.container.textContent).toBe("Ask (GPT-6.1), not model:openai/gpt-4o."); + }); }); describe("ChatMarkdown headings", () => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 59e717e49e3fe..4ac0186ff7ade 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -994,8 +994,12 @@ const CHAT_MARKDOWN_COMPONENTS = { return
{children}
; }, p: function MarkdownParagraph({ node: _node, children, ...props }) { - const { skills, threadRef } = use(ChatMarkdownRendererContext); - return

{renderSkillInlineMarkdownChildren(children, skills, threadRef)}

; + const { skills, threadRef, environmentId } = use(ChatMarkdownRendererContext); + return ( +

+ {renderSkillInlineMarkdownChildren(children, skills, threadRef, environmentId)} +

+ ); }, blockquote: function MarkdownBlockquote({ node: _node, children, ...props }) { const alert = @@ -1030,12 +1034,12 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, li: function MarkdownListItem({ node, children, ...props }) { - const { skills, threadRef } = use(ChatMarkdownRendererContext); + const { skills, threadRef, environmentId } = use(ChatMarkdownRendererContext); // Resolve checkbox markers by offset to avoid rerendering list items on every streamed token. const isTaskItem = String(props.className ?? "").includes("task-list-item"); return (
  • - {renderSkillInlineMarkdownChildren(children, skills, threadRef)} + {renderSkillInlineMarkdownChildren(children, skills, threadRef, environmentId)}
  • ); }, diff --git a/apps/web/src/components/ChatView.logic.browser.test.ts b/apps/web/src/components/ChatView.logic.browser.test.ts index 233af7115eeb3..2d887cb5acc2c 100644 --- a/apps/web/src/components/ChatView.logic.browser.test.ts +++ b/apps/web/src/components/ChatView.logic.browser.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +import { createWorkspaceFocusController } from "../lib/workspaceFocus"; import { shouldRefocusComposerOnWindowFocus } from "./ChatView.logic"; it("keeps keyboard focus on a sidebar row on window return, but not a clicked row", async () => { @@ -26,3 +27,35 @@ it("keeps keyboard focus on a sidebar row on window return, but not a clicked ro expect(document.activeElement).toBe(tabbed); expect(shouldRefocusComposerOnWindowFocus(document.activeElement)).toBe(false); }); + +it("refocuses the composer on window return from a clicked area after a bare key press", async () => { + const controller = createWorkspaceFocusController(document); + const root = document.createElement("div"); + root.innerHTML = + '' + + '
    '; + document.body.append(root); + using _root = { + [Symbol.dispose]: () => { + controller.dispose(); + root.remove(); + }, + }; + const conversation = root.querySelector("section")!; + + await userEvent.click(conversation); + expect(document.activeElement).toBe(conversation); + // A window switch can deliver a lone modifier key without moving focus. + await userEvent.keyboard("{Shift}"); + expect( + shouldRefocusComposerOnWindowFocus(document.activeElement), + "a clicked area counts as reached from the keyboard after a bare key press", + ).toBe(true); + + await userEvent.keyboard("{F6}"); + controller.move(-1); + await userEvent.keyboard("{F6}"); + controller.move(1); + expect(document.activeElement).toBe(conversation); + expect(shouldRefocusComposerOnWindowFocus(document.activeElement)).toBe(false); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e0e415a0788bf..fd52f45558069 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1106,7 +1106,7 @@ export function shouldRefocusComposerOnWindowFocus( } return ( activeElement.closest( - '[data-workspace-area]:focus-visible, [data-sidebar-row]:focus-visible, [role="dialog"], [role="alertdialog"], [role="application"], [role="menu"], [role="listbox"], [data-slot$="-popup"], [data-terminal-owner]', + '[data-workspace-keyboard-focus], [data-sidebar-row]:focus-visible, [role="dialog"], [role="alertdialog"], [role="application"], [role="menu"], [role="listbox"], [data-slot$="-popup"], [data-terminal-owner]', ) === null ); } diff --git a/apps/web/src/components/ChatView.modelPick.browser.test.tsx b/apps/web/src/components/ChatView.modelPick.browser.test.tsx new file mode 100644 index 0000000000000..384d83009d38f --- /dev/null +++ b/apps/web/src/components/ChatView.modelPick.browser.test.tsx @@ -0,0 +1,67 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import type * as Context from "effect/Context"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { AppRoot } from "~/AppRoot"; +// The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines) +// takes 6-11s; importing it here keeps that out of the test's timeout. +import "~/components/ChatView"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { getRouter } from "~/router"; +import { + ENVIRONMENT_ID, + dispatchedCommands, + installFakeEnvironment, + threadWithHistory, + type FakeDriver, +} from "../../test/fakeEnvironment"; +import { render } from "../../test/render"; + +// The socket is the only fake layer; see test/appScenarios.renders.tsx. +vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { + const [actual, { fakeDriver }, Layer] = await Promise.all([ + importOriginal<{ readonly ConnectionDriver: Context.Key }>(), + import("../../test/fakeEnvironment"), + import("effect/Layer"), + ]); + return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) }; +}); + +vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ + ...(await original()), + trackRpcRequestSent: () => {}, +})); + +// Its own file: the app's shell subscription outlives a test, and it must list this thread. +it("a model picked for an existing thread reaches the server before any message is sent", async () => { + const thread = threadWithHistory("thread-model-pick", 1); + using _environment = installFakeEnvironment([thread], { acceptCommands: true }); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }), + ); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByText("Answer 0 for thread-model-pick.")).toBeVisible(); + + await page.getByRole("button", { name: "GPT-5.4", exact: true }).click(); + await page.getByRole("option", { name: /GPT-5\.5/ }).click(); + await vi.waitFor(() => + expect( + dispatchedCommands().filter((command) => command.type === "thread.model-selection.set"), + ).toEqual([ + expect.objectContaining({ + threadId: thread.id, + modelSelection: expect.objectContaining({ instanceId: "codex", model: "gpt-5.5" }), + }), + ]), + ); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e7c814a1a490e..10f9f0492c696 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -229,7 +229,7 @@ import { type TerminalContextDraft } from "../lib/terminalContext"; import { collectInlineContextIds } from "../lib/composerContextReferences"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { useEnvironmentQuery } from "../state/query"; +import { useEnvironmentQueryRefresh } from "../state/query"; import { useEnvironmentScope } from "~/state/session"; import { primaryServerAvailableEditorsAtom, @@ -238,7 +238,7 @@ import { } from "../state/server"; import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; -import { vcsEnvironment } from "../state/vcs"; +import { chatGitStatusAtom, NO_CHAT_GIT_STATUS_ATOM, vcsEnvironment } from "../state/vcs"; import { useWorkspaceRepositories } from "../hooks/useWorkspaceRepositories"; import { type EnvironmentPresentation, @@ -1680,14 +1680,17 @@ export default function ChatView(props: ChatViewProps) { }) : null; const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; - const gitStatusQuery = useEnvironmentQuery( + const gitStatusAtom = gitStatusCwd === null ? null : vcsEnvironment.status({ environmentId, input: { cwd: gitStatusCwd }, - }), - ); + }); + const gitStatusQuery = { + data: useAtomValue(gitStatusAtom ? chatGitStatusAtom(gitStatusAtom) : NO_CHAT_GIT_STATUS_ATOM), + refresh: useEnvironmentQueryRefresh(gitStatusAtom).refresh, + }; useWorkspaceMutationRefresh({ enabled: gitStatusCwd !== null, mutationId: workspaceMutationId, diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 9faeab83c6ffc..112181def5b46 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -1,6 +1,6 @@ import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; import { render } from "../../test/render"; import { openCommandPalette } from "../commandPaletteBus"; @@ -35,6 +35,39 @@ describe("CommandPalette open intents", () => { await expect.element(page.getByPlaceholder("Search...")).toHaveFocus(); }); + it("highlights the first project when New thread in... opens from the root list", async () => { + const environmentId = EnvironmentId.make("local"); + const project = (id: string, title: string): Project => ({ + id: ProjectId.make(id), + environmentId, + title, + workspaceRoot: `/work/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + scripts: [], + }); + setup({ + projects: [project("acme", "Acme"), project("beta", "Beta"), project("gamma", "Gamma")], + }); + await using _screen = await render({null}); + + openCommandPalette({}); + await expect + .element(page.getByPlaceholder("Search commands, projects, and threads...")) + .toHaveFocus(); + await userEvent.keyboard("{ArrowDown}"); + await expect + .element(page.getByRole("option", { name: /^New thread in\.\.\./ })) + .toHaveAttribute("data-highlighted"); + await userEvent.keyboard("{Enter}"); + + await expect.element(page.getByPlaceholder("Search...")).toHaveFocus(); + await expect.element(page.getByRole("option").first()).toHaveAttribute("data-highlighted"); + await expect.element(page.getByRole("option").nth(1)).not.toHaveAttribute("data-highlighted"); + }); + it("opens a search intent with its query in the focused input", async () => { setup(); await using _screen = await render({null}); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 5d08f4c7f6e8d..6352b577eb70a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -73,6 +73,7 @@ import { MonitorIcon, PinIcon, PinOffIcon, + PlugIcon, SquarePenIcon, TextSearchIcon, UserPlusIcon, @@ -247,6 +248,7 @@ import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; import { canShareWithGuestAtom } from "./chat/guestShareAvailability"; import { openGuestShareDialog } from "./chat/guestShareDialogState"; +import { openUseInAnotherApp } from "./mcp/UseInAnotherAppDialog"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { settingsForThisPlatform, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; import { primaryServerKeybindingsAtom } from "../state/server"; @@ -1602,6 +1604,34 @@ function OpenCommandPaletteDialog(props: { }); } + actionItems.push({ + kind: "action", + value: "action:use-in-another-app", + searchTerms: [ + "mcp", + "connector", + "another app", + "poke", + "claude", + "chatgpt", + "grok", + "muse", + "instinct", + "openclaw", + "hermes", + ], + title: "Use T3 Code in another app (MCP)", + icon: , + run: async () => { + openUseInAnotherApp( + currentProjectEnvironmentId ?? + (paletteRouteTarget?.kind === "server" + ? paletteRouteTarget.threadRef.environmentId + : null), + ); + }, + }); + actionItems.push({ kind: "action", value: "action:usage", diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index e3cc3a3791afa..aec99f04b5290 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -1,3 +1,4 @@ +import { useLegendListRef } from "~/hooks/useLegendListRef"; import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronRightIcon } from "lucide-react"; @@ -77,12 +78,11 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { } export function scrollCommandPaletteRowIntoView(list: LegendListRef | null, rowIndex: number) { - const element = list?.getState?.().elementAtIndex(rowIndex); - if (element instanceof HTMLElement) { - element.scrollIntoView({ block: "nearest" }); - return; - } - void list?.scrollIndexIntoView?.({ index: rowIndex, animated: false }); + void list?.scrollIndexIntoView({ + index: rowIndex, + animated: false, + viewportMargin: { start: 24, end: 24 }, + }); } interface CommandPaletteVirtualizedResultsProps extends Omit { @@ -96,15 +96,15 @@ export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualize const { listRef } = props; const [scrollFade, setScrollFade] = useState({ top: false, bottom: false }); const updateScrollFade = () => { - const scrollElement = listRef.current?.getScrollableNode?.(); - if (!(scrollElement instanceof HTMLElement)) return; - const top = scrollElement.scrollTop > 1; - const bottom = - scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 1; + const metrics = listRef.current?.getState(); + if (!metrics) return; + const top = !metrics.isAtStart; + const bottom = !metrics.isAtEnd; setScrollFade((current) => current.top === top && current.bottom === bottom ? current : { top, bottom }, ); }; + const scrollRef = useLegendListRef(listRef, updateScrollFade); if (props.rows.length === 0) { return ; @@ -113,8 +113,9 @@ export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualize return ( - ref={listRef} + ref={scrollRef} data={props.rows} + initialScrollOffset={0} keyExtractor={(row) => row.key} getItemType={(row) => row.kind} extraData={props.highlightedItemValue} @@ -139,9 +140,12 @@ export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualize drawDistance={400} onLayout={updateScrollFade} onScroll={updateScrollFade} + onReady={updateScrollFade} + // Legend counts style padding (not class padding) in its size, end state and row positions. + style={{ paddingTop: 8, paddingBottom: 8 }} contentContainerClassName="px-2" className={cn( - "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2 [&::-webkit-scrollbar-track]:my-2", + "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain [&::-webkit-scrollbar-track]:my-2", getVirtualizedScrollFadeClassName(scrollFade), )} /> diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 8c4698210b1d5..bb8f5a5a2211a 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { documentChipKindForFileName } from "~/pierre-icons"; import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills"; import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; @@ -46,6 +47,7 @@ import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, type RefObject, } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; @@ -90,6 +92,7 @@ import { skillChipReplacements, splitOrLiftListItem, stepCaretAcrossStyledEdge, + type IsKnownModel, type SkillMeta, } from "~/composer-rich-text-doc"; import { @@ -114,6 +117,7 @@ import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; import { FileTagChipContent } from "./chat/FileTagChip"; import { SkillChipPopover, skillPopoverDescription } from "./chat/SkillInlineText"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { ModelMentionChip, modelMentionKey, modelMentionKeysAtom } from "./chat/ModelMentionChip"; import { getTimelinePageScrollKey } from "./chat/pageScrollController"; import { ContextChip } from "./ContextChip"; import { @@ -161,6 +165,8 @@ export interface ComposerPromptEditorProps { skills: ReadonlyArray; disabled: boolean; placeholder: string; + /** Rendered after the placeholder text, such as a key hint. */ + placeholderSuffix?: ReactNode; ariaLabel?: string | undefined; suggestionListId?: string | undefined; activeSuggestionId?: string | undefined; @@ -324,6 +330,45 @@ const ComposerSkillExtension = Node.create({ }, }); +function ComposerModelNodeView({ node }: NodeViewProps) { + const actions = use(ComposerContextActionsContext); + return ( + + + + ); +} + +/** `model:/` from the `@` menu; `source` is the exact prompt text. */ +const ComposerModelExtension = Node.create({ + name: "composer-model", + group: "inline", + inline: true, + atom: true, + selectable: true, + addAttributes() { + return { + instanceId: { default: "" }, + model: { default: "" }, + source: { default: "" }, + }; + }, + parseHTML() { + return [{ tag: "span[data-composer-model]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["span", { "data-composer-model": "", ...HTMLAttributes }]; + }, + addNodeView() { + return ReactNodeViewRenderer(ComposerModelNodeView); + }, +}); + /** Chip presentation for `$name`, or null when the provider does not offer it to users. */ function composerSkillMeta( skills: ReadonlyArray, @@ -769,6 +814,7 @@ function createComposerExtensions(richText: boolean) { ComposerTextDirectionExtension, ComposerMentionExtension, ComposerSkillExtension, + ComposerModelExtension, ComposerCitationExtension, ComposerContextReferenceExtension, ComposerChipSelectionExtension, @@ -876,6 +922,7 @@ function ComposerPromptEditorTiptapInner( containerClassName, className, placeholderClassName, + placeholderSuffix, onChange, onVisibleSelectionChange, onCommandKeyDown, @@ -909,8 +956,13 @@ function ComposerPromptEditorTiptapInner( importContextFragment, })); - // Stable so a skills refresh does not rerun the controlled-value effect. + const { environmentId } = use(ComposerContextActionsContext); + const modelKeys = useAtomValue(modelMentionKeysAtom(environmentId ?? "")); + // Stable so a skills or models refresh does not rerun the controlled-value effect. const skillLabelFor = useStableCallback((name: string) => composerSkillMeta(skills, name)); + const isKnownModel = useStableCallback((instanceId: string, model: string) => + modelKeys.has(modelMentionKey(instanceId, model)), + ); const initialCursor = clampEditorCursor(value, cursor); const initialExpandedCursor = Math.max(0, Math.min(value.length, props.initialExpandedCursor)); @@ -1039,6 +1091,7 @@ function ComposerPromptEditorTiptapInner( content: buildDocJson(value, (name) => composerSkillMeta(skills, name), { styling: richText, literalText, + isKnownModel: (instanceId, model) => modelKeys.has(modelMentionKey(instanceId, model)), }), editable: !disabled, editorProps: { @@ -1336,7 +1389,8 @@ function ComposerPromptEditorTiptapInner( const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); const isChip = (token: (typeof tokens)[number] | undefined) => token?.type === "mention" || - (token?.type === "skill" && skillLabelFor(token.value) !== null); + (token?.type === "skill" && skillLabelFor(token.value) !== null) || + (token?.type === "model" && isKnownModel(token.instanceId, token.model)); const lastToken = tokens.at(-1); if (isChip(lastToken) && lastToken?.end === text.length) { text += " "; @@ -1360,7 +1414,7 @@ function ComposerPromptEditorTiptapInner( insertMarkdownParagraphs( text, skillLabelFor, - { styling: richText, blocks: !nested, literalText, literalLength }, + { styling: richText, blocks: !nested, literalText, literalLength, isKnownModel }, (content) => { editorInstance .chain() @@ -1393,11 +1447,13 @@ function ComposerPromptEditorTiptapInner( }, }, onUpdate: ({ editor: updated, transaction }) => { - // Undo can restore a chip for a skill the current provider no longer offers. + // Undo can restore a chip for a skill or model the environment no longer offers. if (!literalText && isHistoryTransaction(transaction)) { - const stale = skillChipReplacements(updated.state.doc, skillLabelFor).filter( - ({ node }) => node.isText, - ); + const stale = skillChipReplacements( + updated.state.doc, + skillLabelFor, + isKnownModel, + ).filter(({ node }) => node.isText); if (stale.length > 0) { replaceSkillChips(updated, stale); return; @@ -1518,7 +1574,7 @@ function ComposerPromptEditorTiptapInner( if (previousSnapshot.value !== value) { saveCitationDraft(editor, previousSnapshot.value); editor.commands.setContent( - buildDocJson(value, skillLabelFor, { styling: richText, literalText }), + buildDocJson(value, skillLabelFor, { styling: richText, literalText, isKnownModel }), { emitUpdate: false, }, @@ -1559,18 +1615,21 @@ function ComposerPromptEditorTiptapInner( literalText, richText, skillLabelFor, + isKnownModel, value, ]); - // Reconcile loaded or switched provider skills without rebuilding citation node views. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- replaces skill chip nodes in the Tiptap document when the provider skill list loads or changes + // Reconcile loaded or switched provider skills and models without rebuilding citation node views. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- replaces skill and model chip nodes in the Tiptap document when the provider skill or model list loads or changes useEffect(() => { if (!editor || literalText) return; - const replacements = skillChipReplacements(editor.state.doc, (name) => - composerSkillMeta(skills, name), + const replacements = skillChipReplacements( + editor.state.doc, + (name) => composerSkillMeta(skills, name), + (instanceId, model) => modelKeys.has(modelMentionKey(instanceId, model)), ); if (replacements.length > 0) replaceSkillChips(editor, replacements); - }, [editor, literalText, skills]); + }, [editor, literalText, modelKeys, skills]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores ProseMirror focus and caret scroll when the keyed editor remounts, capturing focus on teardown useLayoutEffect(() => { @@ -1786,6 +1845,7 @@ function ComposerPromptEditorTiptapInner( )} > {placeholder} + {placeholderSuffix} ) : null} @@ -1799,7 +1859,13 @@ function ComposerPromptEditorTiptapInner( function insertMarkdownParagraphs( value: string, skillLabelFor: (name: string) => SkillMeta | null, - options: { styling: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number }, + options: { + styling: boolean; + blocks?: boolean; + literalText?: boolean; + literalLength?: number; + isKnownModel?: IsKnownModel; + }, insertContent: (content: JSONContent[] | JSONContent) => void, ): void { const blocks = buildTiptapContent(value, skillLabelFor, options); diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index a536f28ccfb12..13a19167fe929 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -26,6 +26,7 @@ const contextChipVariants = cva( "pr-merged": "[--context-chip-accent:var(--context-chip-pr-merged)]", "pr-closed": "[--context-chip-accent:var(--context-chip-pr-closed)]", skill: "[--context-chip-accent:var(--context-chip-skill)]", + model: "[--context-chip-accent:var(--context-chip-model)]", thread: "[--context-chip-accent:var(--context-chip-thread)]", environment: "[--context-chip-accent:var(--context-chip-environment)]", citation: "[--context-chip-accent:var(--context-chip-citation)]", diff --git a/apps/web/src/components/HostedEmptyState.tsx b/apps/web/src/components/HostedEmptyState.tsx index d7fbb02a28046..627ba0b758eb1 100644 --- a/apps/web/src/components/HostedEmptyState.tsx +++ b/apps/web/src/components/HostedEmptyState.tsx @@ -9,7 +9,7 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "../env"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { useRelayEnvironmentDiscovery } from "../state/environments"; -import { CommandBlock } from "./CommandBlock"; +import { InstallT3Commands } from "./InstallT3Commands"; import { useT3ConnectAuthPrompt } from "./clerk/useT3ConnectAuthPrompt"; import { CloudEnvironmentConnectRows } from "./cloud/CloudEnvironmentConnectList"; import { Button } from "./ui/button"; @@ -88,7 +88,7 @@ function T3ConnectSteps() { )} - + {isSignedIn ? (
    - + '; + document.body.append(previewChrome); + using _chrome = { [Symbol.dispose]: () => previewChrome.remove() }; + + const press = () => userEvent.keyboard("{Control>}{Shift>}]{/Shift}{/Control}"); + const jumpHint = () => document.querySelector("[data-jump-hint]"); + const whileHoldingControl = async (assertJumpHints: () => void) => { + // Every deadline reads as due on subscribe, so hints skip the hold delay. + let now = Date.parse("2026-01-01T00:00:00.000Z"); + using _clock = vi.spyOn(Date, "now").mockImplementation(() => (now += 1000)); + await userEvent.keyboard("{Control>}"); + await act(() => {}); + await vi.waitFor(assertJumpHints); + await userEvent.keyboard("{/Control}"); + }; + const firstPath = `/${ENVIRONMENT_ID}/${first.id}`; + + await userEvent.click(page.getByRole("button", { name: "Reload" })); + await press(); + expect(router.history.location.pathname).toBe(firstPath); + await whileHoldingControl(() => expect(jumpHint()).toBeNull()); + + const firstRef = scopeThreadRef(ENVIRONMENT_ID, first.id); + await act(() => useRightPanelStore.getState().openBrowser(firstRef, null)); + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); + await press(); + expect(router.history.location.pathname).toBe(firstPath); + + await act(() => useRightPanelStore.getState().close(firstRef)); + await press(); + await vi.waitFor(() => + expect(router.history.location.pathname).toBe(`/${ENVIRONMENT_ID}/${second.id}`), + ); + await whileHoldingControl(() => expect(jumpHint()).not.toBeNull()); +}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2454be5f3da3b..c9e3720aeddb2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -100,8 +100,10 @@ import { } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { isPreviewFocused } from "../lib/previewFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { isMacPlatform } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { configuredHostedAppUrl } from "../hostedPairing"; @@ -2352,7 +2354,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve threadKey, currentThreadKey: readRouteThreadKey(router), action: "snooze", - // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309) now: new Date().toISOString(), thread: readThreadShell(threadRef), }) @@ -2361,26 +2362,28 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } return { status: "success" } as const; }; - const attemptSnooze = ( - threadRef: ScopedThreadRef, - preset: Pick, - opts: { coSnoozingKeys?: ReadonlySet } = {}, - ) => { - void (async () => { - const outcome = await performSnooze(threadRef, preset, opts); - if (outcome.status === "failure") { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze thread", - description: - outcome.error instanceof Error ? outcome.error.message : "An error occurred.", - }), - ); - return; - } - })(); - }; + const attemptSnooze = useStableCallback( + ( + threadRef: ScopedThreadRef, + preset: Pick, + opts: { coSnoozingKeys?: ReadonlySet } = {}, + ) => { + void (async () => { + const outcome = await performSnooze(threadRef, preset, opts); + if (outcome.status === "failure") { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze thread", + description: + outcome.error instanceof Error ? outcome.error.message : "An error occurred.", + }), + ); + return; + } + })(); + }, + ); const handleMultiSelectContextMenu = useStableCallback( async (position: { x: number; y: number }) => { @@ -2392,7 +2395,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); if (threadKeys.length === 0) return; const count = threadKeys.length; - // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309) const selectionNow = new Date(); const selectedThreads = threadKeys.flatMap((threadKey) => { const thread = threadByKeyRef.current.get(threadKey); @@ -2423,7 +2425,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve pinnedCount: pinnedSelectedThreads.length, }); // Presets sync across environments, so the first selected thread's stand for all. - // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309) const menuOpenedAt = new Date(); const snoozePresets = resolveSnoozePresets( menuOpenedAt, @@ -2995,6 +2996,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); + const routePreviewOpen = useRightPanelStore( + (state) => selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview", + ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown for thread navigation shortcuts using the current thread list useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { @@ -3006,6 +3010,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve context: { terminalFocus: isTerminalFocused(), terminalOpen: routeTerminalOpen, + previewFocus: isPreviewFocused(), + previewOpen: routePreviewOpen, modelPickerOpen: isModelPickerOpen(), isWeb: !isElectron, isDesktop: isElectron, @@ -3041,6 +3047,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve keybindings, navigateToThread, orderedThreadKeys, + routePreviewOpen, routeRowThreadKey, routeTerminalOpen, threadByKey, @@ -3054,6 +3061,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve context: { terminalFocus: terminalFocused, terminalOpen: routeTerminalOpen, + previewFocus: isPreviewFocused(), + previewOpen: routePreviewOpen, modelPickerOpen: isModelPickerOpen(), isWeb: !isElectron, isDesktop: isElectron, diff --git a/apps/web/src/components/auth/ApproveAgentSurface.tsx b/apps/web/src/components/auth/ApproveAgentSurface.tsx new file mode 100644 index 0000000000000..9f4863fe97d57 --- /dev/null +++ b/apps/web/src/components/auth/ApproveAgentSurface.tsx @@ -0,0 +1,199 @@ +import { useAuth, useClerk } from "@clerk/react"; +import { useAtomValue } from "@effect/atom-react"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { + type ConnectMcpApprovalCall, + ConnectMcpApprovalUnavailableError, +} from "@t3tools/client-runtime/state/connectMcpApproval"; +import { type AuthMcpApprovalDetails, EnvironmentId } from "@t3tools/contracts"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; + +import { isElectron } from "../../env"; +import { runConnectMcpApproval } from "../../state/connectMcpApproval"; +import { resolveClerkSignInProps } from "../clerk/authRedirect"; +import { Button } from "../ui/button"; +import { Spinner } from "../ui/spinner"; +import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page"; +import { + type McpApprovalAnswer, + type McpApprovalTransport, + McpConsent, + readAuthorizationRequest, +} from "./ConnectAgentSurface"; + +const isUnavailable = Schema.is(ConnectMcpApprovalUnavailableError); + +function readEnvironmentId(): EnvironmentId | null { + const value = new URL(window.location.href).searchParams.get("environment"); + return value ? EnvironmentId.make(value) : null; +} + +/** Sends each call with the owner's T3 Connect session and remembers where the environment is. */ +function connectTransport( + environmentId: EnvironmentId, + onEnvironment: (environment: { readonly url: string; readonly label: string }) => void, +): McpApprovalTransport { + const send = async ( + call: ConnectMcpApprovalCall, + ): Promise> => { + const result = await runConnectMcpApproval(environmentId, call); + if (Result.isFailure(result)) { + return { + kind: "error", + message: isUnavailable(result.failure) + ? result.failure.message + : "Could not reach this environment. Try again.", + }; + } + onEnvironment({ + url: result.success.environmentUrl, + label: result.success.environmentLabel, + }); + const { answer } = result.success; + switch (answer.kind) { + case "details": + return { kind: "ok", value: answer.details }; + case "redirect": + return { kind: "redirect", url: answer.url }; + case "refused": + return { kind: "error", message: answer.message }; + } + }; + return { + load: (authorization) => send({ kind: "approval", payload: authorization }), + decide: (authorization, decision) => + send({ kind: "decision", payload: { authorization, decision } }), + }; +} + +/** + * The consent screen for an agent sign-in on a T3 Connect environment: the owner signs in to + * T3 Connect here and approves with that session, so no sign-in code is needed. + */ +export function ApproveAgentSurface() { + const [environmentId] = useState(readEnvironmentId); + const [authorization] = useState(readAuthorizationRequest); + // Another site could frame this page and steer a click onto Approve. + const [framed] = useState(() => window.top !== window.self); + const [environment, setEnvironment] = useState<{ + readonly url: string; + readonly label: string; + } | null>(null); + const [transport] = useState(() => + environmentId === null ? null : connectTransport(environmentId, setEnvironment), + ); + const clerk = useClerk(); + const { isLoaded, isSignedIn } = useAuth(); + const session = useAtomValue(managedRelaySessionAtom); + const signInOpened = useRef(false); + + const openSignIn = () => + clerk.openSignIn(resolveClerkSignInProps(window.location.href, isElectron)); + const openSignInOnLoad = useEffectEvent(openSignIn); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens Clerk sign-in once Clerk has loaded and nobody is signed in + useEffect(() => { + if (!isLoaded || isSignedIn || signInOpened.current || framed) return; + signInOpened.current = true; + openSignInOnLoad(); + }, [framed, isLoaded, isSignedIn]); + + if (environmentId === null || transport === null) { + return ( + + ); + } + + if (framed) { + return ( + + ); + } + + if (!isLoaded || (isSignedIn && session === null)) { + return ( + + + + + ); + } + + if (!isSignedIn) { + return ( + +
    + +
    +
    + ); + } + + const search = new URLSearchParams(window.location.search); + search.delete("environment"); + return ( + + {environment ? ( + + ) : null} + +
    + } + /> + ); +} + +function Message({ + title, + description, + children, +}: { + readonly title: string; + readonly description: string; + readonly children?: ReactNode; +}) { + return ( + + + {children} + + ); +} diff --git a/apps/web/src/components/auth/ConnectAgentSurface.logic.test.ts b/apps/web/src/components/auth/ConnectAgentSurface.logic.test.ts index b5d7d8ebc9900..a7a0a05602d4c 100644 --- a/apps/web/src/components/auth/ConnectAgentSurface.logic.test.ts +++ b/apps/web/src/components/auth/ConnectAgentSurface.logic.test.ts @@ -1,13 +1,64 @@ import { expect, it } from "vite-plus/test"; -import { RuntimeMode } from "@t3tools/contracts"; +import { AuthMcpClientAccess, RuntimeMode } from "@t3tools/contracts"; -import { resolveMcpAccess } from "./ConnectAgentSurface.logic"; +import { + agentRedirectUrl, + resolveApprovalDecision, + resolveMcpAccess, +} from "./ConnectAgentSurface.logic"; it.each(RuntimeMode.literals)("keeps MCP access within the %s policy ceiling", (maxRuntimeMode) => { const ceiling = { organization: "Example", maxRuntimeMode }; - expect(resolveMcpAccess("auto", ceiling)).toBe( + expect(resolveMcpAccess("auto", ceiling, false)).toBe( maxRuntimeMode === "full-access" ? "auto" : maxRuntimeMode, ); - expect(resolveMcpAccess("read-only", ceiling)).toBe("read-only"); - expect(resolveMcpAccess("approval-required", ceiling)).toBe("approval-required"); + expect(resolveMcpAccess("read-only", ceiling, false)).toBe("read-only"); + expect(resolveMcpAccess("approval-required", ceiling, false)).toBe("approval-required"); +}); + +it("checks a typed code even in a signed-in browser, and approves without one at Read only", () => { + const signedOut = { + clientName: "Poke", + redirectHost: "poke.com", + environmentHost: "prod-a1.t3coderelay.com", + }; + const details = { ...signedOut, csrfToken: "csrf", oneClickAccess: AuthMcpClientAccess.literals }; + const access = resolveMcpAccess(null, undefined, false); + + // A typed code starts at Auto, and an expired one must fail rather than fall back to this session. + expect( + resolveApprovalDecision({ + code: " 7K2P9QXR4MZT ", + access: resolveMcpAccess(null, undefined, true), + details, + }), + ).toEqual({ _tag: "pairing-code", access: "auto", code: "7K2P9QXR4MZT" }); + expect(resolveApprovalDecision({ code: "auto-q8rz3nwd5ktc", access, details })).toEqual({ + _tag: "pairing-code", + access: "auto", + code: "auto-q8rz3nwd5ktc", + }); + expect(resolveApprovalDecision({ code: "", access, details })).toEqual({ + _tag: "browser-session", + access: "read-only", + csrfToken: "csrf", + }); + expect(resolveApprovalDecision({ code: "", access, details: signedOut })).toBeNull(); +}); + +it("follows only https or loopback http redirects to an agent", () => { + expect(agentRedirectUrl("https://www.cursor.com/cb?code=x")).toBe( + "https://www.cursor.com/cb?code=x", + ); + expect(agentRedirectUrl("http://localhost:51234/callback?code=x")).toBe( + "http://localhost:51234/callback?code=x", + ); + for (const url of [ + "javascript:alert(document.domain)", + "data:text/html,hi", + "http://evil.example/cb", + "not a url", + ]) { + expect(agentRedirectUrl(url), url).toBeNull(); + } }); diff --git a/apps/web/src/components/auth/ConnectAgentSurface.logic.ts b/apps/web/src/components/auth/ConnectAgentSurface.logic.ts index 351716b8e9682..b796c6a55c85e 100644 --- a/apps/web/src/components/auth/ConnectAgentSurface.logic.ts +++ b/apps/web/src/components/auth/ConnectAgentSurface.logic.ts @@ -1,16 +1,65 @@ import { + type AuthMcpApprovalDecision, type AuthMcpApprovalDetails, type AuthMcpClientAccess, isRuntimeModeAbove, + parseMcpApprovalCode, } from "@t3tools/contracts"; +import { isLoopbackHost } from "@t3tools/shared/preview"; +/** + * The picker's access, lowered to a managed ceiling: the user's choice, else Auto for a typed + * code, else Read only, since approving without a code says nothing about what the user wants. + */ export function resolveMcpAccess( - access: AuthMcpClientAccess, + requested: AuthMcpClientAccess | null, ceiling: AuthMcpApprovalDetails["runtimeModeCeiling"], -) { + hasCode: boolean, +): AuthMcpClientAccess { + const access = requested ?? (hasCode ? "auto" : "read-only"); return access !== "read-only" && ceiling !== undefined && isRuntimeModeAbove(access, ceiling.maxRuntimeMode) ? ceiling.maxRuntimeMode : access; } + +/** + * What Approve sends. A typed code is always checked as a code, even when the browser's + * session could approve, and a level-bound code sends its own level. Null: nothing to send. + */ +export function resolveApprovalDecision(input: { + readonly code: string; + readonly access: AuthMcpClientAccess; + readonly details: AuthMcpApprovalDetails; +}): Exclude | null { + const code = input.code.trim(); + if (code.length > 0) { + return { + _tag: "pairing-code", + access: parseMcpApprovalCode(code)?.access ?? input.access, + code, + }; + } + const { csrfToken, oneClickAccess } = input.details; + return csrfToken !== undefined && (oneClickAccess ?? []).includes(input.access) + ? { _tag: "browser-session", access: input.access, csrfToken } + : null; +} + +/** + * The address to send the window to after a decision, or null. Only https, or http on this + * computer, can carry a sign-in to an agent; anything else, such as `javascript:`, is refused. + */ +export function agentRedirectUrl(url: string): string | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + return parsed.protocol === "https:" || + (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname)) + ? parsed.toString() + : null; +} diff --git a/apps/web/src/components/auth/ConnectAgentSurface.tsx b/apps/web/src/components/auth/ConnectAgentSurface.tsx index ec6aa5b3abc6d..9d03091433610 100644 --- a/apps/web/src/components/auth/ConnectAgentSurface.tsx +++ b/apps/web/src/components/auth/ConnectAgentSurface.tsx @@ -6,6 +6,7 @@ import { AuthMcpAuthorizationRequest, isRuntimeModeAbove, managedRuntimeModeCeilingMessage, + parseMcpApprovalCode, } from "@t3tools/contracts"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { isLoopbackHost } from "@t3tools/shared/preview"; @@ -18,7 +19,11 @@ import { type ReactNode, useEffect, useState } from "react"; import { PrimaryEnvironmentHttpClient } from "~/environments/primary/httpClient"; import { runPrimaryHttp } from "~/lib/runtime"; import { cn } from "~/lib/utils"; -import { resolveMcpAccess } from "./ConnectAgentSurface.logic"; +import { + agentRedirectUrl, + resolveApprovalDecision, + resolveMcpAccess, +} from "./ConnectAgentSurface.logic"; import { runtimeModeConfig } from "../chat/runtimeModeConfig"; import { Alert, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; @@ -39,6 +44,15 @@ export const accessConfig: Record< ...runtimeModeConfig, }; +const ARM_DELAY_MS = 700; +const UNSAFE_REDIRECT = "The environment returned an address this page will not open."; + +function followRedirect(url: string): boolean { + const safe = agentRedirectUrl(url); + if (safe !== null) window.location.replace(safe); + return safe !== null; +} + function redirectsToThisComputer(redirectHost: string): boolean { return isLoopbackHost(new URL(`http://${redirectHost}`).hostname); } @@ -51,99 +65,159 @@ type Loaded = const UNREACHABLE = "Could not reach this environment. Try again."; const isApprovalError = Schema.is(AuthMcpApprovalError); -type Answer = +export type McpApprovalAnswer = | { readonly kind: "ok"; readonly value: A } | { readonly kind: "redirect"; readonly url: string } | { readonly kind: "error"; readonly message: string }; +/** Where the consent page reads the sign-in request and sends the decision. */ +export interface McpApprovalTransport { + readonly load: ( + authorization: AuthMcpAuthorizationRequest, + ) => Promise>; + readonly decide: ( + authorization: AuthMcpAuthorizationRequest, + decision: AuthMcpApprovalDecision, + ) => Promise>; +} + function runApproval( call: ( client: Context.Service.Shape, ) => Effect.Effect, -): Promise> { +): Promise> { return runPrimaryHttp( PrimaryEnvironmentHttpClient.pipe( Effect.flatMap(call), - Effect.map((value): Answer => + Effect.map((value): McpApprovalAnswer => typeof value === "object" && value !== null && "redirectTo" in value ? { kind: "redirect", url: value.redirectTo } : { kind: "ok", value: value as A }, ), Effect.catch((error) => - Effect.succeed>({ + Effect.succeed>({ kind: "error", message: isApprovalError(error) ? error.message : UNREACHABLE, }), ), ), - ).catch((): Answer => ({ kind: "error", message: UNREACHABLE })); + ).catch((): McpApprovalAnswer => ({ kind: "error", message: UNREACHABLE })); } +/** This environment's own page, approving with a code or this browser's session. */ +const primaryTransport: McpApprovalTransport = { + load: (authorization) => + runApproval((client) => client.mcpOAuth.approval({ headers: {}, payload: authorization })), + decide: (authorization, decision) => + runApproval((client) => + client.mcpOAuth.decision({ headers: {}, payload: { authorization, decision } }), + ), +}; + const decodeAuthorizationRequest = Schema.decodeSync(AuthMcpAuthorizationRequest); -function readAuthorizationRequest(): AuthMcpAuthorizationRequest { +/** The agent's OAuth request from this page's query; other parameters are ignored. */ +export function readAuthorizationRequest(): AuthMcpAuthorizationRequest { return decodeAuthorizationRequest(Object.fromEntries(new URL(window.location.href).searchParams)); } -/** Sessions can grant only scopes they hold. */ -function oneClickApproves(details: AuthMcpApprovalDetails, access: AuthMcpClientAccess) { - return details.csrfToken !== undefined && (details.oneClickAccess ?? []).includes(access); -} - export function ConnectAgentSurface() { const [authorization] = useState(readAuthorizationRequest); + return ; +} + +/** + * The consent screen for one agent sign-in. `acceptsCode` shows the sign-in code field; + * `requireLevelChoice` starts with no level picked; `footer` sits under the actions. + */ +export function McpConsent({ + authorization, + transport, + acceptsCode = false, + requireLevelChoice = false, + environmentLabel, + footer, +}: { + readonly authorization: AuthMcpAuthorizationRequest; + readonly transport: McpApprovalTransport; + readonly acceptsCode?: boolean; + readonly requireLevelChoice?: boolean; + readonly environmentLabel?: string | undefined; + readonly footer?: ReactNode; +}) { const [loaded, setLoaded] = useState({ status: "loading" }); - const [requestedAccess, setAccess] = useState("auto"); - const access = resolveMcpAccess( - requestedAccess, - loaded.status === "ready" ? loaded.details.runtimeModeCeiling : undefined, - ); + const [requestedAccess, setAccess] = useState(null); const [pairingCode, setPairingCode] = useState(""); + const [armed, setArmed] = useState(false); + const access = + requireLevelChoice && requestedAccess === null + ? null + : resolveMcpAccess( + requestedAccess, + loaded.status === "ready" ? loaded.details.runtimeModeCeiling : undefined, + pairingCode.trim().length > 0, + ); const [errorMessage, setErrorMessage] = useState(""); const [pending, setPending] = useState<"approve" | "deny" | null>(null); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fetches the MCP OAuth approval and redirects the window or stores details, ignoring stale answers useEffect(() => { let cancelled = false; - void runApproval((client) => client.mcpOAuth.approval({ payload: authorization })).then( - (answer) => { - if (cancelled) return; - if (answer.kind === "redirect") { - window.location.replace(answer.url); - return; - } - setLoaded( - answer.kind === "error" - ? { status: "invalid", message: answer.message } - : { status: "ready", details: answer.value }, - ); - }, - ); + void transport.load(authorization).then((answer) => { + if (cancelled) return; + if (answer.kind === "redirect") { + if (!followRedirect(answer.url)) setLoaded({ status: "invalid", message: UNSAFE_REDIRECT }); + return; + } + setLoaded( + answer.kind === "error" + ? { status: "invalid", message: answer.message } + : { status: "ready", details: answer.value }, + ); + }); return () => { cancelled = true; }; - }, [authorization]); + }, [authorization, transport]); - const decide = async (choice: "approve" | "deny") => { + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps Approve off briefly after the page shows or regains focus, so a click aimed at another window cannot land on it + useEffect(() => { if (loaded.status !== "ready") return; - setPending(choice); - setErrorMessage(""); - const { csrfToken } = loaded.details; - const decision: AuthMcpApprovalDecision = + let timer = window.setTimeout(() => setArmed(true), ARM_DELAY_MS); + const rearm = () => { + setArmed(false); + window.clearTimeout(timer); + timer = window.setTimeout(() => setArmed(true), ARM_DELAY_MS); + }; + window.addEventListener("focus", rearm); + document.addEventListener("visibilitychange", rearm); + return () => { + window.clearTimeout(timer); + window.removeEventListener("focus", rearm); + document.removeEventListener("visibilitychange", rearm); + }; + }, [loaded.status]); + + const decide = async (choice: "approve" | "deny", details: AuthMcpApprovalDetails) => { + const decision: AuthMcpApprovalDecision | null = choice === "deny" ? { _tag: "deny" } - : csrfToken !== undefined && oneClickApproves(loaded.details, access) - ? { _tag: "browser-session", access, csrfToken } - : { _tag: "pairing-code", access, code: pairingCode.trim() }; - const answer = await runApproval((client) => - client.mcpOAuth.decision({ payload: { authorization, decision } }), - ); - if (answer.kind === "redirect") { - window.location.replace(answer.url); - return; - } + : access === null + ? null + : resolveApprovalDecision({ code: pairingCode, access, details }); + if (decision === null) return; + setPending(choice); + setErrorMessage(""); + const answer = await transport.decide(authorization, decision); + if (answer.kind === "redirect" && followRedirect(answer.url)) return; setPending(null); - setErrorMessage(answer.kind === "error" ? answer.message : "The sign-in could not continue."); + setErrorMessage( + answer.kind === "error" + ? answer.message + : answer.kind === "redirect" + ? UNSAFE_REDIRECT + : "The sign-in could not continue.", + ); }; if (loaded.status === "loading") { @@ -165,13 +239,20 @@ export function ConnectAgentSurface() {

    Close this page and start the sign-in again from your agent.

    + {footer} ); } const { details } = loaded; - const oneClick = oneClickApproves(details, access); - const canApprove = pending === null && (oneClick || pairingCode.trim().length > 0); + const canApprove = + armed && + pending === null && + access !== null && + resolveApprovalDecision({ code: pairingCode, access, details }) !== null; + // A code from "Use T3 Code in another app" names its level, so the page does not ask again. + const codeAccess = parseMcpApprovalCode(pairingCode)?.access; + const approveLevel = codeAccess ?? access; return ( @@ -180,60 +261,47 @@ export function ConnectAgentSurface() { description={ <> This agent wants to use the threads in every project on{" "} - {details.environmentHost}. + + {environmentLabel ?? details.environmentHost} + + {environmentLabel ? ` (${details.environmentHost})` : null}. } /> -

    - {redirectsToThisComputer(details.redirectHost) ? ( - <> - The name is chosen by the agent. Approval returns to {details.redirectHost} on the - computer that opened this page. Only approve a sign-in you just started. - - ) : ( - <> - The name is chosen by the agent. Approval gives access to whoever runs{" "} - {details.redirectHost}. Only - approve a sign-in you just started there. - - )} -

    +
    +
    App
    +
    + {details.clientName} · name set by the app +
    +
    Access goes to
    +
    + {details.redirectHost} + {redirectsToThisComputer(details.redirectHost) ? ( + + {" "} + on the computer that opened this page + + ) : ( + + {" "} + · whoever runs it gets this access + + )} +
    +
    +

    Only approve a sign-in you just started.

    { event.preventDefault(); - if (canApprove) void decide("approve"); + if (canApprove) void decide("approve", details); }} > -
    - - What it may do - - setAccess(value as AuthMcpClientAccess)} - > - {AuthMcpClientAccess.literals.map((option) => ( - - ))} - -

    - Beyond read only, it can start, message and stop threads, and none of them can run with - more than the mode you pick. -

    -
    - - {oneClick ? null : ( + {acceptsCode ? (
    setPairingCode(event.currentTarget.value)} - placeholder="Paste a one-time pairing code" + placeholder="Paste a code" spellCheck={false} value={pairingCode} />

    - Create one in Settings → Sharing & access, or run t3 auth pairing create{" "} - on this machine. + Get one from Use T3 Code in another app (MCP), Settings → Sharing & access, or{" "} + t3 auth pairing create on this machine. +

    +
    + ) : null} + + {codeAccess ? ( +

    + Access{" "} + {accessConfig[codeAccess].label} +

    + ) : ( +
    + + What it may do + + setAccess(value as AuthMcpClientAccess)} + > + {AuthMcpClientAccess.literals.map((option) => ( + + ))} + +

    + Beyond read only, it can start, message and stop threads, and none of them can run + with more than the mode you pick.

    )} @@ -262,11 +361,15 @@ export function ConnectAgentSurface() {
    + {footer}
    ); } @@ -298,20 +402,25 @@ function ceilingReason(details: AuthMcpApprovalDetails, access: AuthMcpClientAcc : managedRuntimeModeCeilingMessage(ceiling.organization, ceiling.maxRuntimeMode); } -function AccessOption({ +/** One access level in a radio group; `description` replaces the level's own, null hides it. */ +export function AccessOption({ access, selected, - managedReason, + disabledReason, + description, }: { readonly access: AuthMcpClientAccess; readonly selected: boolean; - readonly managedReason: string | undefined; + readonly disabledReason: string | undefined; + readonly description?: string | null; }) { - const { label, description, icon: Icon } = accessConfig[access]; + const { label, icon: Icon } = accessConfig[access]; + const detail = + disabledReason ?? (description === undefined ? accessConfig[access].description : description); return ( {label} - {managedReason ?? description} + {detail === null ? null : ( + {detail} + )} ); diff --git a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx index e1b66e1fed8f2..214e4efd69b9f 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx @@ -1,5 +1,6 @@ import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, + type AssistantCitation, EnvironmentId, MessageId, ThreadId, @@ -50,12 +51,16 @@ function setup() { return { sourceAnchor, [Symbol.dispose]: () => source.remove() }; } -function mount(sourceAnchor: AssistantCitationSourceAnchor, onSave: () => boolean) { +function mount( + sourceAnchor: AssistantCitationSourceAnchor | undefined, + onSave: () => boolean, + chipCitation: AssistantCitation = citation, +) { function Composer() { const [open, setOpen] = useState(true); return ( @@ -128,6 +133,25 @@ describe("citation comment source disappearance", () => { }); }); +describe("citation comment caret", () => { + it("opens a saved comment with the caret after its end, scrolled into view", async () => { + using _source = setup(); + const comment = `${"line\n".repeat(40)}end`; + await using _screen = await mount( + undefined, + vi.fn(() => true), + { ...citation, comment }, + ); + await vi.waitFor(() => expect(document.activeElement).toBe(textarea())); + const input = textarea()!; + + expect(input.scrollHeight).toBeGreaterThan(input.clientHeight); + expect(input.scrollTop).toBe(input.scrollHeight - input.clientHeight); + await userEvent.keyboard("!"); + expect(input.value).toBe(`${comment}!`); + }); +}); + describe("citation comment popup position", () => { it("opens at its anchor instead of sliding in from the window corner", async () => { using source = setup(); diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index cf62358069701..ea35016c5d3ac 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -193,7 +193,13 @@ export function AssistantCitationChip({ align="end" anchor={popupAnchor} initialFocus={() => { - commentInputRef.current?.focus({ preventScroll: true }); + const input = commentInputRef.current; + if (input) { + input.focus({ preventScroll: true }); + input.setSelectionRange(input.value.length, input.value.length); + // Moving the caret doesn't scroll a long comment to it. + input.scrollTop = input.scrollHeight; + } return false; }} finalFocus={ diff --git a/apps/web/src/components/chat/BackgroundWorkDetails.browser.test.tsx b/apps/web/src/components/chat/BackgroundWorkDetails.browser.test.tsx new file mode 100644 index 0000000000000..ea51febae202d --- /dev/null +++ b/apps/web/src/components/chat/BackgroundWorkDetails.browser.test.tsx @@ -0,0 +1,95 @@ +import "~/index.css"; +import { EnvironmentId, ProviderDriverKind, ThreadId, TurnItemId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +vi.mock(import("./CommandOutputPanel"), () => ({ + useCommandOutputStreaming: () => true, + CommandOutputPanel: () =>
    Command output stream
    , +})); + +import { render } from "../../../test/render"; +import { backgroundWorkNowItem } from "./ThreadActivityBanners"; +import { ThreadNow } from "./ThreadNow"; + +it("opens full command details for short labels and keeps pending tasks current", async () => { + const command = "python3 audit.py -j 6\n> scratch/audit.log 2>&1"; + const commandItem = (status: "running" | "completed") => ({ + id: TurnItemId.make("bash-1"), + threadId: ThreadId.make("thread"), + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status, + title: null, + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-11-09T00:00:00Z"), + type: "command_execution" as const, + input: command, + output: "Command running in background with ID: native-task", + }); + const view = ( + description?: string, + status: "running" | "completed" = "completed", + explicitItem = true, + ) => ( + undefined, + onOpenThread: () => undefined, + })} + /> + ); + await using screen = await render(view("Audit")); + await screen.getByRole("button", { name: "Show what is running", exact: true }).click(); + await vi.waitFor(() => expect(page.getByRole("dialog").element().textContent).toContain(command)); + await expect.element(page.getByText("Command output stream")).not.toBeInTheDocument(); + + await screen.rerender(view(command)); + await vi.waitFor(() => expect(page.getByRole("dialog").element().textContent).toContain(command)); + await screen.rerender(view()); + await vi.waitFor(() => expect(page.getByRole("dialog").element().textContent).toContain(command)); + + await screen.rerender(view("Check remaining modules", "running")); + await expect.element(page.getByRole("dialog").getByText("Check remaining modules")).toBeVisible(); + await expect.element(page.getByText("Command output stream")).toBeVisible(); + await screen.rerender(view("Native fallback", "completed", false)); + await expect.element(page.getByText("Command output stream")).toBeVisible(); + await userEvent.keyboard("{Escape}"); + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/BackgroundWorkDetails.tsx b/apps/web/src/components/chat/BackgroundWorkDetails.tsx index 5efa255adc543..41784198a3691 100644 --- a/apps/web/src/components/chat/BackgroundWorkDetails.tsx +++ b/apps/web/src/components/chat/BackgroundWorkDetails.tsx @@ -1,4 +1,9 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { + EnvironmentId, + OrchestrationV2PendingBackgroundTask, + ThreadId, +} from "@t3tools/contracts"; +import type { ThreadStructure } from "@t3tools/client-runtime/state/shell"; import type { PendingBackgroundWorkItem } from "@t3tools/client-runtime/state/thread-execution"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { @@ -47,6 +52,32 @@ const KIND_NAME: Record = { background_task: "Task", }; +/** Fills a task's command and live output from its turn item when the task itself lacks them. */ +function withTurnItemDetails( + item: PendingBackgroundWorkItem, + task: OrchestrationV2PendingBackgroundTask | undefined, + turnItems: ThreadStructure["turnItems"], + threadId: ThreadId, +): PendingBackgroundWorkItem { + if (item.kind === "subagent" || (item.command && item.output)) return item; + const turnItem = turnItems.findLast((candidate) => + task?.itemId === undefined + ? (candidate.nativeItemRef?.nativeId || candidate.id) === item.taskId + : candidate.id === task.itemId, + ); + const commandItem = turnItem?.type === "command_execution" ? turnItem : undefined; + const outputItemId = task?.itemId ?? commandItem?.id; + return { + ...item, + command: item.command ?? task?.command ?? commandItem?.input, + output: + item.output ?? + (outputItemId !== undefined && turnItem?.status === "running" + ? { threadId: task?.sourceThreadId ?? turnItem.threadId ?? threadId, itemId: outputItemId } + : undefined), + }; +} + /** Whole seconds, ticking; mounted only inside the open popover. */ function RunningFor(props: { readonly startedAt: number }) { const elapsed = Math.floor((useNowSecond() - props.startedAt) / 1_000) * 1_000; @@ -69,14 +100,25 @@ function RunningDot() { */ export function BackgroundWorkDetails(props: { readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; readonly items: ReadonlyArray; + readonly tasks: ReadonlyArray; + readonly turnItems: ThreadStructure["turnItems"]; readonly onOpenThread: (threadId: ThreadId) => void; }) { const [selectedTaskId, setSelectedTaskId] = useState(null); // A single task needs no list: show it straight away. const onlyItem = props.items.length === 1 ? props.items[0] : undefined; + const picked = onlyItem ?? props.items.find((item) => item.taskId === selectedTaskId); const selected = - onlyItem ?? props.items.find((item) => item.taskId === selectedTaskId) ?? undefined; + picked === undefined + ? undefined + : withTurnItemDetails( + picked, + props.tasks.find((task) => task.taskId === picked.taskId), + props.turnItems, + props.threadId, + ); return ( (open ? undefined : setSelectedTaskId(null))}> diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ea57b56fe0bff..e56d3e3941952 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -49,7 +49,9 @@ import { import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Schema from "effect/Schema"; +import { formatComposerModelMention } from "@t3tools/client-runtime/composer-model-mentions"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { splitTransferredFiles } from "./workspaceFileDrop"; @@ -213,6 +215,7 @@ import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDetail.logic"; import { getComposerPromptInjectionState, getComposerReasoningStep } from "./composerProviderState"; import { readPromptUltrathink } from "./TraitsPicker"; +import { useApplyThreadModelPick } from "./useApplyThreadModelPick"; import { providerSupportsManualCompaction, resolveContextWindowModelDisplayName, @@ -268,6 +271,7 @@ import { resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { Kbd } from "../ui/kbd"; import { useSoftwareKeyboardOpen } from "../../hooks/useSoftwareKeyboardOpen"; import { useStableCallback } from "../../hooks/useStableCallback"; import { usePanelAnimationSettings } from "../../panelAnimations"; @@ -288,6 +292,8 @@ import { } from "./preparingComposerImages"; import { ComposerControlsBar } from "./ComposerControlsBar"; import { composerCommandKeyHandler } from "./composerCommandKey"; +import { usePromptSuggestion } from "./usePromptSuggestion"; +import { useFreshSessionArmed } from "./freshSessionArms"; function consumePasteAsTextShortcut(deadline: { current: number }): boolean { const armed = Date.now() <= deadline.current; @@ -649,6 +655,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const activePendingIsResponding = !holdingUserInput && incomingPendingIsResponding; const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined; const isEditingQueuedMessage = editingQueuedAttachments !== null; + const applyThreadModelPick = useApplyThreadModelPick(); const composerDraft = useComposerThreadDraft(composerDraftTarget); const composerDraftTargetKeyRef = useRef(""); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed draft target key to a ref read by async handlers @@ -808,11 +815,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? needsReattachFileCount === 1 ? "Attach the interrupted file again or remove it" : "Attach the interrupted files again or remove them" - : attachmentUploadBlockReason({ - imageIds: [...composerImages, ...composerFiles].map((attachment) => attachment.id), - uploadsByImageId, - environmentId, - }) + : // A plain send waits for its uploads. Answers and approval redirects need them first. + questionAttachmentTarget || + pendingApprovals.some((approval) => !heldRequestIds.has(approval.requestId)) + ? attachmentUploadBlockReason({ + imageIds: [...composerImages, ...composerFiles].map((attachment) => attachment.id), + uploadsByImageId, + environmentId, + }) + : null : null); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); @@ -1185,6 +1196,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showSkillsInSlashMenu: settings.showSkillsInSlashMenu, workspaceSkillsScanning, questionAttachmentTarget, + modelMentions: { instanceEntries: providerInstanceEntries, modelOptionsByInstance }, }); const composerMenuOpen = Boolean(composerTrigger); @@ -2097,6 +2109,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.type === "model") { + if (trigger.kind !== "path") return; + const replacement = `${formatComposerModelMention(item.instanceId, item.model)} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } if (item.type === "environment") { if (trigger.kind !== "path") return; const record = environmentContextRecord(item.environmentId, item.label); @@ -2344,6 +2372,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const expandMobileComposerStable = useStableCallback(expandMobileComposer); + // Escape dismisses the suggestion so Tab moves focus again. + const { suggestion: promptSuggestion, dismiss: dismissPromptSuggestion } = usePromptSuggestion( + environmentId, + activeThread, + ); + // After /clear the next message starts a fresh session that cannot see what the suggestion refers to. + const freshSessionArmed = useFreshSessionArmed(scopedThreadKey(routeThreadRef)); + // Offered only where the default placeholder would show, so it never hides a pending question. + const offeredPromptSuggestion = + phase !== "running" && + !isSendBusy && + !freshSessionArmed && + !speechInput.freezesEditor && + composerContextRecords.size === 0 && + !isComposerApprovalState && + !activePendingProgress && + !projectSelectionRequired && + !showProviderUnavailable + ? promptSuggestion + : null; + const onComposerCommandKey = (key: string, event: KeyboardEvent, isTaskItem?: boolean) => composerCommandKeyHandler({ keybindings, @@ -2379,6 +2428,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) readComposerSnapshot, applyPromptReplacement, submitComposer, + promptSuggestion: offeredPromptSuggestion, + dismissPromptSuggestion, })(key, event, isTaskItem); const { @@ -3214,6 +3265,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) model: selectedModel, persistSticky: true, }); + applyThreadModelPick(composerDraftTarget); if (change.prompt !== promptRef.current) { applyPromptReplacementStable(0, promptRef.current.length, change.prompt, { expandedCursorAfterReplace: change.cursor, @@ -3838,7 +3890,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Choose a project above to start a thread" : showProviderUnavailable ? "Enable a provider in Settings to send a message" - : DEFAULT_COMPOSER_PLACEHOLDER + : (offeredPromptSuggestion ?? DEFAULT_COMPOSER_PLACEHOLDER) + } + placeholderSuffix={ + offeredPromptSuggestion !== null ? ( + + ⇥ Tab + + ) : undefined } disabled={ projectSelectionRequired || diff --git a/apps/web/src/components/chat/ChatHeader.browser.test.tsx b/apps/web/src/components/chat/ChatHeader.browser.test.tsx index 115de39efe25d..8acebdfa0bb41 100644 --- a/apps/web/src/components/chat/ChatHeader.browser.test.tsx +++ b/apps/web/src/components/chat/ChatHeader.browser.test.tsx @@ -3,6 +3,7 @@ import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; const persisted = vi.hoisted(() => ({ title: "" })); +const openMenu = vi.hoisted(() => vi.fn()); vi.mock(import("../../state/session"), async (importOriginal) => ({ ...(await importOriginal()), useEnvironmentScope: () => true, @@ -17,10 +18,10 @@ vi.mock(import("../../state/use-orchestration-command"), async (importOriginal) }) as never, })); vi.mock(import("~/hooks/useThreadActionMenu"), () => ({ - useThreadActionMenu: () => ({ openMenu: () => {}, closeMenu: () => {} }), + useThreadActionMenu: () => ({ openMenu, closeMenu: () => {} }), })); -import { render } from "../../../test/render"; +import { render, stubGlobal } from "../../../test/render"; import { ChatHeader } from "./ChatHeader"; function header(title: string) { @@ -71,3 +72,11 @@ it.each(["blur", "Enter"] as const)( expect(persisted.title).toBe("My edited title"); }, ); + +it("opens the thread menu on a desktop title click without waiting", async () => { + openMenu.mockClear(); + using _bridge = stubGlobal("desktopBridge", {}); + await using screen = await render(header("New thread")); + await screen.getByRole("button", { name: "Thread actions for New thread" }).click(); + expect(openMenu).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index bde04a6b9ca24..5cd649d90e3ea 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -70,8 +70,6 @@ function resolveRenameCommit(input: { return { action: "commit", title: trimmed }; } -// Delay the input-swallowing native menu through the double-click rename interval. -const TITLE_MENU_OPEN_DELAY_MS = 500; export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeThreadId, @@ -208,54 +206,21 @@ export const ChatHeader = memo(function ChatHeader({ onFind: onFindInThread, canShareWithGuest, }); - const titleButtonRef = useRef(null); - const titleMenuTimerRef = useRef(null); - const cancelPendingTitleMenu = () => { - if (titleMenuTimerRef.current === null) return; - clearTimeout(titleMenuTimerRef.current); - titleMenuTimerRef.current = null; - }; - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the pending title-menu timer when the thread switches or the header unmounts - useEffect( - () => () => { - if (titleMenuTimerRef.current === null) return; - clearTimeout(titleMenuTimerRef.current); - titleMenuTimerRef.current = null; - }, - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A pending title menu belongs to the thread that was clicked, so switching threads drops it. - [activeThreadEnvironmentId, activeThreadId], - ); - const openTitleMenuNow = () => { - cancelPendingTitleMenu(); - const rect = titleButtonRef.current?.getBoundingClientRect(); - if (!rect) return; - openMenu({ x: rect.left, y: rect.bottom + 4 }); - }; const openMenuFromTitle = (event: ReactMouseEvent) => { if (isTrailingDoubleClick(event.detail)) return; - const clickedChevron = - (event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null; - if (event.detail === 0 || clickedChevron || window.desktopBridge === undefined) { - openTitleMenuNow(); - return; - } - cancelPendingTitleMenu(); - titleMenuTimerRef.current = window.setTimeout(() => { - titleMenuTimerRef.current = null; - openTitleMenuNow(); - }, TITLE_MENU_OPEN_DELAY_MS); + const rect = event.currentTarget.getBoundingClientRect(); + openMenu({ x: rect.left, y: rect.bottom + 4 }); }; + // A native menu takes the second click, so only the in-page menu lets a double-click reach here. const handleTitleDoubleClick = (event: ReactMouseEvent) => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; if ((event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null) return; - cancelPendingTitleMenu(); closeMenu(); startRename(); }; const handleHeaderContextMenu = (event: ReactMouseEvent) => { if (renamingTitle !== null) return; if (!isServerThread && onOpenProjectSettings === undefined) return; - cancelPendingTitleMenu(); event.preventDefault(); if (!isServerThread) { const api = readLocalApi(); @@ -383,13 +348,11 @@ export const ChatHeader = memo(function ChatHeader({ } diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index 0710f2534dada..581cd5a446c39 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -77,8 +77,8 @@ function Attachment({ className, ...props }: ComponentProps<"div">) { data-slot="composer-banner-attachment" className={cn( "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2*var(--chat-composer-drawer-inset))]", - "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0", - "[&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", + "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0 [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:[--composer-banner-corner-top:var(--control-radius)]", + "[&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0 [&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:[--composer-banner-corner-top:var(--control-radius)]", className, )} {...props} @@ -124,16 +124,19 @@ function Root({ return ( [data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-se-xl", - "[&>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>:last-child>[data-slot=button]:last-of-type]:rounded-se-xl", - "[&>:first-child>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-se-xl", + "[&>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-se-(--composer-banner-corner-top)", + "[&>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>:last-child>[data-slot=button]:last-of-type]:rounded-se-(--composer-banner-corner-top)", + "[&>:first-child>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-se-(--composer-banner-corner-top)", + "data-[composer-banner-surface=detached]:[&>[data-composer-banner-row]:only-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-ee-xl", + "data-[composer-banner-surface=detached]:[&>[data-composer-banner-row]:only-child>[data-slot=composer-banner-actions]>:last-child>[data-slot=button]:last-of-type]:rounded-ee-xl", ], className, )} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 0b9dda460d8ef..a49966eb2dc68 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -9,6 +9,7 @@ import { type EnvironmentMachineKind, type ProjectEntry, type ProviderDriverKind, + type ProviderInstanceId, type PullRequestContextMetadata, type ScopedThreadRef, type ServerProviderSkill, @@ -31,6 +32,7 @@ import { Badge } from "../ui/badge"; import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { PierreEntryIcon } from "./PierreEntryIcon"; +import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ComposerBanner } from "./ComposerBanner"; import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation"; @@ -88,6 +90,18 @@ export type ComposerCommandItem = machine: EnvironmentMachineKind; label: string; description: string; + } + | { + id: string; + type: "model"; + instanceId: ProviderInstanceId; + model: string; + driverKind: ProviderDriverKind; + providerDisplayName: string; + acpRegistryAgentId: string | undefined; + acpRegistryIconUrl: string | undefined; + label: string; + description: string; }; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { @@ -221,6 +235,16 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { className="size-4 shrink-0 text-secondary-label" /> ) : null} + {props.item.type === "model" ? ( + + ) : null} {pullRequestPresentation ? ( applyThreadModelPick(routeThreadRef)); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, instanceId: selectedInstanceId, - ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "server" + ? { threadRef: routeThreadRef, onDraftModelOptionsChange: applyRouteThreadModelPick } + : {}), ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, @@ -132,7 +138,9 @@ export function ComposerControlsBar(props: { const providerTraitsPickerInput = { provider: selectedProvider, instanceId: selectedInstanceId, - ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "server" + ? { threadRef: routeThreadRef, onDraftModelOptionsChange: applyRouteThreadModelPick } + : {}), ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, diff --git a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx index 0dc5b367b4e34..ff14738766920 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx @@ -79,4 +79,73 @@ describe("ExpandedImageDialog", () => { await userEvent.keyboard("{Escape}"); await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()); }); + + it("scrolling pans the image while Ctrl+wheel zooms around the pointer", async () => { + const canvas = document.createElement("canvas"); + canvas.width = 1600; + canvas.height = 1200; + await using screen = await render( + {}} + />, + // The dialog renders no links and reads no route. + { router: false }, + ); + const viewportLocator = screen.getByRole("region", { name: "large.png, zoomable image" }); + const viewport = viewportLocator.element() as HTMLElement; + const image = screen.getByRole("img", { name: "large.png" }).element() as HTMLElement; + await vi.waitFor(() => expect(image.style.width).not.toBe("")); + const fitWidth = image.getBoundingClientRect().width; + const wheels: { ctrlKey: boolean; canceled: boolean }[] = []; + const recordWheel = (event: WheelEvent) => + wheels.push({ ctrlKey: event.ctrlKey, canceled: event.defaultPrevented }); + document.addEventListener("wheel", recordWheel); + using _wheelListener = { + [Symbol.dispose]: () => document.removeEventListener("wheel", recordWheel), + }; + // Wheels land on the viewport's center; this is the image fraction under it. + const pointerFraction = () => { + const viewportBounds = viewport.getBoundingClientRect(); + const imageBounds = image.getBoundingClientRect(); + return { + x: (viewportBounds.left + viewportBounds.width / 2 - imageBounds.left) / imageBounds.width, + y: (viewportBounds.top + viewportBounds.height / 2 - imageBounds.top) / imageBounds.height, + }; + }; + const ctrlWheel = async (deltaY: number) => { + await userEvent.keyboard("{Control>}"); + await userEvent.wheel(viewportLocator, { delta: { y: deltaY } }); + await userEvent.keyboard("{/Control}"); + }; + + await userEvent.wheel(viewportLocator, { delta: { y: 120 } }); + expect(wheels.at(-1)).toEqual({ ctrlKey: false, canceled: false }); + expect(image.getBoundingClientRect().width).toBe(fitWidth); + + await ctrlWheel(-100); + expect(wheels.at(-1)).toEqual({ ctrlKey: true, canceled: true }); + const zoomedWidth = image.getBoundingClientRect().width; + expect(zoomedWidth).toBeCloseTo(fitWidth * Math.E, 0); + + const pan = async (delta: { x: number; y: number }) => { + const { scrollLeft, scrollTop } = viewport; + await userEvent.wheel(viewportLocator, { delta }); + await vi.waitFor(() => { + expect(Math.sign(viewport.scrollLeft - scrollLeft)).toBe(Math.sign(delta.x)); + expect(Math.sign(viewport.scrollTop - scrollTop)).toBe(Math.sign(delta.y)); + }); + }; + await pan({ x: 60, y: 0 }); + await pan({ x: 0, y: 60 }); + await pan({ x: -40, y: -40 }); + expect(wheels.filter((wheel) => !wheel.ctrlKey).every((wheel) => !wheel.canceled)).toBe(true); + expect(image.getBoundingClientRect().width).toBe(zoomedWidth); + + const anchor = pointerFraction(); + await ctrlWheel(-50); + expect(image.getBoundingClientRect().width).toBeGreaterThan(zoomedWidth); + expect(pointerFraction().x).toBeCloseTo(anchor.x, 2); + expect(pointerFraction().y).toBeCloseTo(anchor.y, 2); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 823cd3c7cc6da..f7cf81d85e224 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -262,6 +262,41 @@ describe("MessagesTimeline work rows", () => { }); }); + it("keeps following a streaming turn after a heading is expanded at the bottom", async () => { + const props = { ...baseProps(), isWorking: true }; + const runId = RunId.make("live-run"); + const container = document.createElement("div"); + container.style.cssText = + "height: min(600px, 100vh); width: 900px; display: flex; flex-direction: column;"; + const entries = [ + ...Array.from({ length: 12 }, (_, index) => + message(`Earlier context ${index}`, {}, `context-${index}`), + ), + message("Progress details\n\n".repeat(50), { role: "assistant", runId }, "progress"), + message("Done", { role: "assistant", runId }, "final"), + ]; + await using screen = await render(, { + container, + }); + const heading = screen.getByRole("button", { name: /^Worked for/ }); + await expect.element(heading).toHaveStyle({ pointerEvents: "auto" }); + const viewport = props.listRef.current!.getScrollableNode()!; + const distanceFromEnd = () => + viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop; + await vi.waitFor(() => expect(distanceFromEnd()).toBeLessThan(2)); + await heading.click(); + await screen.rerender( + , + ); + await vi.waitFor(() => expect(distanceFromEnd()).toBeLessThan(2)); + }); + it.each(["delegated", "legacy", "missing"] as const)( "uses the reported task for %s completion notifications", async (source) => { @@ -1251,21 +1286,79 @@ describe("MessagesTimeline thread find", () => { }); describe("compact reasoning math", () => { - it("keeps formulas while flattening compact reasoning previews", () => { - const tree = parseMarkdownWithMath("## Reasoning\n\nUse **norm** $|x|$ and \\[x^2\\]"); + it.each([ + [ + "## Reasoning\n\nUse **norm** $|x|$ and \\[x^2\\]", + [ + ["text", "Reasoning Use norm "], + ["inlineMath", "|x|"], + ["text", " and "], + ["inlineMath", "x^2"], + ], + ], + [ + " Lead **bold**\n\n *em* `code` \n\n- one\n- two\n\n> quote\n> more\n\ntrail ", + [["text", "Lead bold em code one two quote more trail"]], + ], + [ + "$a$ starts, ends with $b$", + [ + ["inlineMath", "a"], + ["text", " starts, ends with "], + ["inlineMath", "b"], + ], + ], + [ + "before $x$\n\n$$\ny\n$$\n\n after", + [ + ["text", "before "], + ["inlineMath", "x"], + ["text", " "], + ["inlineMath", "y"], + ["text", " after"], + ], + ], + [ + "a *b*$c$**d** e", + [ + ["text", "a b"], + ["inlineMath", "c"], + ["text", "d e"], + ], + ], + [ + "$a$![](x.png)$b$", + [ + ["inlineMath", "a"], + ["text", ""], + ["inlineMath", "b"], + ], + ], + [ + "| a | b |\n| - | - |\n| $x$ | y z |", + [ + ["text", "a b "], + ["inlineMath", "x"], + ["text", " y z"], + ], + ], + [ + 'See [a link](https://example.com "t") ![alt text](img.png) bold \n\n[ref]: https://example.com\n\n:::note\nbody\n:::', + [["text", "See a link alt text bold :::note body :::"]], + ], + [ + "line one\r\nline two\r\n\r\nnext  nbsp wide 😀𝑥
end", + [["text", "line one line two next nbsp wide 😀𝑥 end"]], + ], + ["\\$5 and \\$6, \\*not em\\*", [["text", "$5 and $6, *not em*"]]], + ["hard \nbreak\\\nagain", [["text", "hard break again"]]], + ["
    only html
    ", [["text", "Reasoning"]]], + ])("keeps formulas while flattening compact reasoning previews: %j", (source, expected) => { + const tree = parseMarkdownWithMath(source); remarkThoughtPreview("Reasoning")(tree); - expect(tree.children.map((node) => node.type)).toEqual([ - "text", - "inlineMath", - "text", - "inlineMath", - ]); - expect(tree.children.map((node) => ("value" in node ? node.value : null))).toEqual([ - "Reasoning Use norm ", - "|x|", - " and ", - "x^2", - ]); + expect(tree.children.map((node) => [node.type, "value" in node ? node.value : null])).toEqual( + expected, + ); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2fe29a285bd45..b2c6cb983a406 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -477,52 +477,40 @@ const ConversationTimeline = memo(function ConversationTimeline({ const citationThreadRef = parseScopedThreadKey(routeThreadKey); const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined); const [minimapStripMap] = useState(() => new Map()); - const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); - const disclosureAnchorKeyRef = useRef(null); - const disclosureSettleFrameRef = useRef(null); - const disclosureSettleSecondFrameRef = useRef(null); const normalizedFindQuery = findQuery.trim(); const findActive = findExpanded; - const disclosureCollapsedRef = useRef(false); - - const scheduleDisclosureSettle = () => { - if (disclosureSettleFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleFrameRef.current); - } - if (disclosureSettleSecondFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleSecondFrameRef.current); + // Fork: a collapse that leaves the reader at the end lets the composer open again. + const collapsedAtEndFrameRef = useRef(null); + const cancelCollapsedAtEndCheck = () => { + if (collapsedAtEndFrameRef.current !== null) { + cancelAnimationFrame(collapsedAtEndFrameRef.current); + collapsedAtEndFrameRef.current = null; } - disclosureSettleFrameRef.current = requestAnimationFrame(() => { - disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { - disclosureAnchorKeyRef.current = null; - setDisclosureToggleSettling(false); - disclosureSettleFrameRef.current = null; - disclosureSettleSecondFrameRef.current = null; - if ( - disclosureCollapsedRef.current && - resolveTimelineIsAtEnd(listRef.current?.getState()) === true - ) { + }; + // Two frames, so the list has measured the collapsed rows. + const scheduleCollapsedAtEndCheck = () => { + cancelCollapsedAtEndCheck(); + collapsedAtEndFrameRef.current = requestAnimationFrame(() => { + collapsedAtEndFrameRef.current = requestAnimationFrame(() => { + collapsedAtEndFrameRef.current = null; + if (resolveTimelineIsAtEnd(listRef.current?.getState()) === true) { onToolOutputCollapsedAtEnd?.(); } }); }); }; - const suspendEndScrollMaintenanceForDisclosure = (anchorKey: string, collapsed = false) => { - disclosureAnchorKeyRef.current = anchorKey; - disclosureCollapsedRef.current = collapsed; - setDisclosureToggleSettling(true); - scheduleDisclosureSettle(); - }; - - const shouldRestoreVisibleContentPosition = (row: MessagesTimelineRow) => { - const disclosureAnchorKey = disclosureAnchorKeyRef.current; - return disclosureAnchorKey === null || row.id === disclosureAnchorKey; + const preserveDisclosurePosition = (anchorKey: string) => { + const list = listRef.current; + // An anchor ends end-follow until the reader scrolls, so a streaming turn keeps the live end. + if (!list || (isWorking && list.getState().isFollowingEnd)) return; + list.cancelScroll(); + list.anchorVisibleContent({ key: anchorKey }); }; const onToggleTurnFold = (runId: RunId) => { - suspendEndScrollMaintenanceForDisclosure(`turn-fold:${runId}`); + preserveDisclosurePosition(`turn-fold:${runId}`); setExpandedRunIds((existing) => { const next = new Set(existing); if (next.has(runId)) { @@ -534,7 +522,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ }); }; const onToggleWorkGroup = (groupId: string, anchorKey: string) => { - suspendEndScrollMaintenanceForDisclosure(anchorKey, expandedWorkGroupIds.has(groupId)); + preserveDisclosurePosition(anchorKey); + if (expandedWorkGroupIds.has(groupId)) scheduleCollapsedAtEndCheck(); setExpandedWorkGroupIds((existing) => { const next = new Set(existing); if (next.has(groupId)) { @@ -546,7 +535,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ }); }; const onToggleAttemptFold = (attemptId: RunAttemptId) => { - suspendEndScrollMaintenanceForDisclosure(`attempt-fold:${attemptId}`); + preserveDisclosurePosition(`attempt-fold:${attemptId}`); setExpandedAttemptIds((existing) => { const next = new Set(existing); if (next.has(attemptId)) { @@ -865,7 +854,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ const maintainVisibleContentPosition = { data: true, size: true, - shouldRestorePosition: shouldRestoreVisibleContentPosition, }; const measureContentOverflow = () => timelineContentOverflowsViewport(listRef.current?.getState?.(), { @@ -900,7 +888,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ firstBufferedIndex == null ? undefined : state.elementAtIndex(firstBufferedIndex); const firstBufferedTop = firstBufferedIndex == null ? undefined : state.positionAtIndex(firstBufferedIndex); - const viewport: HTMLElement | undefined = listRef.current?.getScrollableNode(); + const viewport = listRef.current?.getScrollableNode() ?? undefined; // Cached row positions exclude the header and pending scroll adjustments. const scroll = firstBufferedRow && viewport && firstBufferedTop !== undefined @@ -997,9 +985,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ }; const handleItemSizeChanged = () => { - if (disclosureAnchorKeyRef.current !== null) { - scheduleDisclosureSettle(); - } reportContentOverflow(); scheduleMinimapRefresh(); }; @@ -1098,7 +1083,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ onToggleTurnFold, onToggleAttemptFold, onToggleWorkGroup, - onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, + onToggleWorkEntry: preserveDisclosurePosition, onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation: onRetryWorkspacePreparation ?? null, @@ -1216,9 +1201,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ detachViewport?.(); viewport?.dispose(); if (viewportRef.current === viewport) viewportRef.current = null; - // Refresh frames only read the list; disclosure frames still run so the - // settle flag clears. cancelContentOverflowFrame(); + cancelCollapsedAtEndCheck(); if (minimapRefreshFrameRef.current !== null) { cancelAnimationFrame(minimapRefreshFrameRef.current); minimapRefreshFrameRef.current = null; @@ -1389,8 +1373,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ (restoringThreadPosition && rememberedPosition?.atEnd === false) || anchoredEndSpace || !liveFollowEnabled || - fullscreenAppRowId !== null || - disclosureToggleSettling + fullscreenAppRowId !== null ? false : isWorking && !prefersReducedMotion && settlingListIdentity === null ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH @@ -1478,11 +1461,14 @@ function keyExtractor(item: MessagesTimelineRow) { return item.id; } -// LegendList averages by type; length buckets prevent inflated estimates and nested-update loops. +// LegendList estimates unmeasured rows from their type's average. Long messages of equal length +// can differ in height by orders of magnitude, so each gets its own type to avoid nested-update loops. function getItemType(item: MessagesTimelineRow) { if (item.kind !== "message") return item.kind; const lengthBucket = Math.max(0, Math.floor(Math.log2(item.message.text.length / 1000))); - return `message:${item.message.role}:${lengthBucket}`; + return lengthBucket < 2 + ? `message:${item.message.role}:${lengthBucket}` + : `message:${item.message.id}`; } const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { diff --git a/apps/web/src/components/chat/ModelMentionChip.tsx b/apps/web/src/components/chat/ModelMentionChip.tsx new file mode 100644 index 0000000000000..03521bfce9114 --- /dev/null +++ b/apps/web/src/components/chat/ModelMentionChip.tsx @@ -0,0 +1,125 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, ProviderDriverKind } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/reactivity"; + +import { brandProviderInstanceEntries, deriveProviderInstanceEntries } from "~/providerInstances"; +import { serverEnvironment } from "~/state/server"; +import { contextChipAccentWithHueOf } from "~/themePalette"; +import { ContextChipPopover } from "../contextChipParts"; +import { ProviderInstanceIcon, providerLogoColor } from "./ProviderInstanceIcon"; +import { getDisplayModelName } from "./providerIconUtils"; + +interface ModelMentionPresentation { + readonly label: string; + readonly providerLabel: string; + readonly driverKind: ProviderDriverKind; + readonly providerDisplayName: string; + readonly acpRegistryAgentId: string | undefined; + readonly acpRegistryIconUrl: string | undefined; +} + +function presentationsEqual(left: ModelMentionPresentation, right: ModelMentionPresentation) { + return ( + left.label === right.label && + left.providerLabel === right.providerLabel && + left.driverKind === right.driverKind && + left.providerDisplayName === right.providerDisplayName && + left.acpRegistryAgentId === right.acpRegistryAgentId && + left.acpRegistryIconUrl === right.acpRegistryIconUrl + ); +} + +// Keyed per mention so provider status pushes don't rerender every message that names a model. +const modelMentionPresentationAtom = Atom.family((key: string) => + Atom.make((get): ModelMentionPresentation | null => { + const [environmentId, instanceId, model] = key.split("\u0000") as [string, string, string]; + if (!environmentId) return null; + const config = get(serverEnvironment.configValueAtom(environmentId as EnvironmentId)); + const provider = config?.providers.find((candidate) => candidate.instanceId === instanceId); + const option = provider?.models.find((candidate) => candidate.slug === model); + if (!config || !provider || !option) return null; + const [entry] = brandProviderInstanceEntries( + deriveProviderInstanceEntries([provider]), + config.settings, + ); + if (!entry) return null; + const next: ModelMentionPresentation = { + label: getDisplayModelName(option), + providerLabel: option.subProvider ?? entry.displayName, + driverKind: entry.driverKind, + providerDisplayName: entry.displayName, + acpRegistryAgentId: entry.acpRegistryAgentId, + acpRegistryIconUrl: entry.acpRegistryIconUrl, + }; + const previous = Option.getOrNull(get.self()); + return previous && presentationsEqual(previous, next) ? previous : next; + }).pipe(Atom.withLabel("model-mention-presentation")), +); + +export function modelMentionKey(instanceId: string, model: string): string { + return `${instanceId}\u0000${model}`; +} + +// Keeps its identity until the environment's models change, so status pushes don't reconcile the composer. +export const modelMentionKeysAtom = Atom.family((environmentId: string) => + Atom.make((get): ReadonlySet => { + const config = environmentId + ? get(serverEnvironment.configValueAtom(environmentId as EnvironmentId)) + : null; + const next = new Set( + config?.providers.flatMap((provider) => + provider.models.map((option) => modelMentionKey(provider.instanceId, option.slug)), + ), + ); + const previous = Option.getOrNull(get.self>()); + return previous && previous.size === next.size && [...next].every((key) => previous.has(key)) + ? previous + : next; + }).pipe(Atom.withLabel("model-mention-keys")), +); + +function logoAccent(driverKind: ProviderDriverKind): string | undefined { + const color = providerLogoColor(driverKind); + return (color && contextChipAccentWithHueOf("model", color)) || undefined; +} + +export function ModelMentionChip(props: { + environmentId: EnvironmentId | null; + instanceId: string; + model: string; + source: string; +}) { + const presentation = useAtomValue( + modelMentionPresentationAtom( + `${props.environmentId ?? ""}\u0000${modelMentionKey(props.instanceId, props.model)}`, + ), + ); + if (!presentation) return props.source; + const accent = logoAccent(presentation.driverKind); + return ( + + } + label={presentation.label} + accessibleLabel={`Model ${presentation.label}`} + copyMarkdown={props.source} + > +
    +

    {presentation.label}

    +

    {presentation.providerLabel}

    +

    {props.source}

    +
    +
    + ); +} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 67b09c36cd30c..283660f4f84f9 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -1,3 +1,4 @@ +import { useLegendListRef } from "~/hooks/useLegendListRef"; import { ANTIGRAVITY_DEFAULT_MODEL, AuthOrchestrationReadScope, @@ -31,7 +32,7 @@ import { parseModelPickerLegacySectionKey, parseModelPickerModelKey, } from "./modelPickerKeys"; -import { rankModelPickerSearch } from "./modelPickerSearch"; +import { rankModelPickerSearch } from "@t3tools/client-runtime/model-search"; import { Combobox, ComboboxEmpty, @@ -266,37 +267,12 @@ function ModelPickerList({ const [showTopScrollFade, setShowTopScrollFade] = useState(false); const [showBottomScrollFade, setShowBottomScrollFade] = useState(false); const updateScrollFades = () => { - const scrollElement = listRef.current?.getScrollableNode(); - if (!(scrollElement instanceof HTMLElement)) { - return; - } - const maxScrollOffset = Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight); - setShowTopScrollFade(scrollElement.scrollTop > 1); - setShowBottomScrollFade(maxScrollOffset - scrollElement.scrollTop > 1); + const metrics = listRef.current?.getState(); + if (!metrics) return; + setShowTopScrollFade(!metrics.isAtStart); + setShowBottomScrollFade(!metrics.isAtEnd); }; - - // Disabled-reason closures rebuild identical key arrays; compare contents. - const itemListKey = JSON.stringify(itemKeys); - const [fadesResetFor, setFadesResetFor] = useState(itemListKey); - if (fadesResetFor !== itemListKey) { - setFadesResetFor(itemListKey); - setShowTopScrollFade(false); - setShowBottomScrollFade(itemKeys.length > 5); - } - const measureScrollFades = useEffectEvent(updateScrollFades); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the list scroller in animation frames after a new list lays out to set scroll fades - useLayoutEffect(() => { - let nestedFrame = 0; - const frame = window.requestAnimationFrame(() => { - measureScrollFades(); - nestedFrame = window.requestAnimationFrame(() => measureScrollFades()); - }); - return () => { - window.cancelAnimationFrame(frame); - window.cancelAnimationFrame(nestedFrame); - }; - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A new list re-measures its scroll fades once it has laid out. - }, [itemListKey]); + const scrollRef = useLegendListRef(listRef, updateScrollFades); return (
    - ref={listRef} + ref={scrollRef} data={itemKeys} extraData={extraData} keyExtractor={(modelKey) => modelKey} @@ -319,6 +295,7 @@ function ModelPickerList({ ItemSeparatorComponent={ModelListSeparator} onLayout={updateScrollFades} onScroll={updateScrollFades} + onReady={updateScrollFades} className={cn( "scrollbar-gutter-stable h-full overflow-x-hidden overscroll-y-contain py-1.5 [&::-webkit-scrollbar-track]:my-2", getVirtualizedScrollFadeClassName({ diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index 720ce4d6c443d..684db30988f3e 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -194,6 +194,8 @@ describe("remote editor links", () => { await using fixture = await setup(); const { screen, pressOpenFavorite, FavoriteEditorFallback } = fixture; state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }; + // A successful open would announce on a real timer that fires in the next test. + state.openUrl.mockResolvedValueOnce(false); await act(async () => screen.rerender()); expect((await pressOpenFavorite()).defaultPrevented).toBe(true); diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 07cab305bbd5b..4ec73220a23b7 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -7,7 +7,15 @@ import { } from "@t3tools/client-runtime/state/provider-instance-display"; import { ProviderDriverKind } from "@t3tools/contracts"; -import { AntigravityIcon, ClaudeAI, Icon, OpenAI } from "../Icons"; +import { + AnthropicIcon, + AntigravityIcon, + ClaudeAI, + Icon, + OllamaIcon, + OpenAI, + OpenRouterIcon, +} from "../Icons"; import { cn } from "~/lib/utils"; import { providerClients } from "../settings/providerDriverMeta"; @@ -22,14 +30,35 @@ const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, + // Text generators, by the service they reach (see textGeneratorBrand). + [ProviderDriverKind.make("openai")]: OpenAI, + [ProviderDriverKind.make("anthropic")]: AnthropicIcon, + [ProviderDriverKind.make("openrouter")]: OpenRouterIcon, + [ProviderDriverKind.make("ollama")]: OllamaIcon, }; const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: "text-black dark:text-white", [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]", [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]", + [ProviderDriverKind.make("openai")]: "text-black dark:text-white", + [ProviderDriverKind.make("anthropic")]: "text-black dark:text-white", + [ProviderDriverKind.make("openrouter")]: "text-black dark:text-white", + [ProviderDriverKind.make("ollama")]: "text-black dark:text-white", +}; + +const BUNDLED_LOGO_COLOR_BY_PROVIDER: Partial> = { + [ProviderDriverKind.make("claudeAgent")]: "#d97757", + [ProviderDriverKind.make("antigravity")]: "#5b87bf", }; +/** The logo's own color, or null for logos that swap between black and white per theme. */ +export function providerLogoColor(driverKind: ProviderDriverKind): string | null { + const fill = providerClients.get(driverKind)?.icon?.fill; + if (fill) return fill.light === fill.dark ? fill.light : null; + return BUNDLED_LOGO_COLOR_BY_PROVIDER[driverKind] ?? null; +} + export function providerTextColor(driverKind: ProviderDriverKind): { readonly className?: string; readonly style?: CSSProperties; @@ -86,6 +115,8 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { const badgeContent = props.badgeContent ?? "initials"; const iconClassName = cn(props.size === "compact" && "size-3.5", props.iconClassName); const isAcpRegistry = props.driverKind === "acpRegistry"; + // A custom OpenAI-compatible endpoint has no brand: show its name, marked with the protocol. + const isCustomEndpoint = props.driverKind === "openaiCompatible"; const acpRegistryIconUrl = resolveProviderInstanceAcpRegistryIconUrl({ driverKind: props.driverKind, agentId: props.acpRegistryAgentId, @@ -115,6 +146,29 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { fallbackClassName="size-full" icon={acpRegistryIconUrl} /> + ) : isCustomEndpoint ? ( + // The tile is a size container: below ~18px it keeps one letter and drops the mark. + + {Array.from(providerInstanceInitials(props.displayName))[0]} + + {Array.from(providerInstanceInitials(props.displayName)).slice(1).join("")} + + + + + ) : packageIcon ? ( )} - {props.showBadge ? ( + {isCustomEndpoint ? null : props.showBadge ? ( { + const environmentId = props.environmentId; + if (environmentId === null) return; + // An older server drops the field and probes every provider. + const config = registry.get(serverEnvironment.configValueAtom(environmentId)); + if (config?.environment.capabilities.modelCatalogRefresh !== true) return; + void refreshProviders({ environmentId, input: { modelCatalogOnly: true } }); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- asks the server for newly released models on every open; a parent, a keybinding or the palette can open the picker, so no one handler sees them all + useEffect(() => { + if (isMenuOpen) refreshModelCatalog(); + }, [isMenuOpen]); const searchInputRef = useRef(null); const size = props.size ?? "sm"; diff --git a/apps/web/src/components/chat/SecretRequestCard.tsx b/apps/web/src/components/chat/SecretRequestCard.tsx index 6a78a7415575f..b6cdabb8a7b69 100644 --- a/apps/web/src/components/chat/SecretRequestCard.tsx +++ b/apps/web/src/components/chat/SecretRequestCard.tsx @@ -4,6 +4,7 @@ import { secretRequestAnswerInput, secretRequestDisplay, secretRequestFailureMessage, + secretRequestLink, secretRequestPrivacyNote, secretRequestReference, secretRequestSources, @@ -21,7 +22,7 @@ import type { SecretProviderId, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { CheckIcon, LockIcon, MinusIcon, ShieldCheckIcon } from "lucide-react"; +import { CheckIcon, ExternalLinkIcon, LockIcon, MinusIcon, ShieldCheckIcon } from "lucide-react"; import { useId, useRef, useState, type FormEvent } from "react"; import { isSecretsFeatureEnabled } from "../../env"; @@ -125,6 +126,7 @@ function PendingSecretRequestForm(props: { } }; const draft = source === null ? secret : reference; + const link = secretRequestLink(item); const describedBy = error !== null ? `${privacyId} ${errorId}` : privacyId; return ( @@ -151,6 +153,22 @@ function PendingSecretRequestForm(props: {

    {item.reason}

    ) : null}
    + {link !== null ? ( +
    + + {/* A link found in the reason is already shown there. */} + {item.url !== undefined ? ( +

    {item.url}

    + ) : null} +
    + ) : null}
    {sources.length > 0 ? ( ; threadRef: ScopedThreadRef | undefined; + environmentId: EnvironmentId | null; }) { - const nodes: ReactNode[] = []; - let cursor = 0; - + const chips: Array<{ start: number; end: number; node: ReactNode }> = []; for (const { start, end, skill, rawText } of matchInlineSkills(props.text, props.skills)) { - if (start > cursor) { - nodes.push(props.text.slice(cursor, start)); - } - nodes.push( - , - ); - cursor = end; + chips.push({ + start, + end, + node: ( + + ), + }); } - - if (cursor === 0) { + for (const mention of collectComposerModelMentions(props.text)) { + chips.push({ + start: mention.start, + end: mention.end, + node: ( + + ), + }); + } + if (chips.length === 0) { return <>{props.text}; } + + const nodes: ReactNode[] = []; + let cursor = 0; + for (const chip of chips.toSorted((left, right) => left.start - right.start)) { + if (chip.start < cursor) continue; + if (chip.start > cursor) { + nodes.push(props.text.slice(cursor, chip.start)); + } + nodes.push(chip.node); + cursor = chip.end; + } if (cursor < props.text.length) { nodes.push(props.text.slice(cursor)); } @@ -45,10 +71,18 @@ export function renderSkillInlineMarkdownChildren( children: ReactNode, skills: ReadonlyArray, threadRef: ScopedThreadRef | undefined, + environmentId: EnvironmentId | null, ): ReactNode { return Children.map(children, (child) => { if (typeof child === "string") { - return ; + return ( + + ); } if (!isValidElement<{ children?: ReactNode; node?: { tagName?: string } }>(child)) { return child; @@ -64,7 +98,7 @@ export function renderSkillInlineMarkdownChildren( return cloneElement( child, undefined, - renderSkillInlineMarkdownChildren(child.props.children, skills, threadRef), + renderSkillInlineMarkdownChildren(child.props.children, skills, threadRef, environmentId), ); }); } diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index 0517dbab6a1a1..2768fc91ac942 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -102,19 +102,18 @@ export function SubagentTooltipContent(props: { descriptor.type === "boolean" && typeof saved?.value === "boolean" ) { - return getTraitsSpeedDisplay(provider.driver, { + return getTraitsSpeedDisplay({ ...descriptor, currentValue: saved.value, }); } if ( - provider.driver === "codex" && descriptor.id === "serviceTier" && descriptor.type === "select" && typeof saved?.value === "string" && descriptor.options.some((option) => option.id === saved.value) ) { - return getTraitsSpeedDisplay(provider.driver, { + return getTraitsSpeedDisplay({ ...descriptor, currentValue: saved.value, }); diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 6528a59bdbc1d..886498de90f96 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -256,7 +256,10 @@ export function backgroundWorkNowItem(input: { {/* Fork: what each piece of work runs, with live output. */} void input.onStop()}> diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts b/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts index 4a067b7de1ce5..17f7420cb1551 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts @@ -196,15 +196,19 @@ export function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconNa export function remarkThoughtPreview(fallback: string) { return (tree: Root) => { const children: RootContent[] = []; + let run: string[] = []; const append = (value: string) => { - const last = children.at(-1); - const text = value.replace(/\s+/g, " "); - if (last?.type === "text") last.value = (last.value + text).replace(/\s+/g, " "); - else children.push({ type: "text", value: text }); + run.push(value); + }; + const flush = () => { + if (run.length === 0) return; + children.push({ type: "text", value: run.join("").replace(/\s+/g, " ") }); + run = []; }; const visit = (node: Root | RootContent) => { if (node.type === "html" || node.type === "definition") return; if (node.type === "math" || node.type === "inlineMath") { + flush(); children.push({ type: "inlineMath", value: node.value, @@ -229,6 +233,7 @@ export function remarkThoughtPreview(fallback: string) { } else if (node.type === "break") append(" "); }; visit(tree); + flush(); const first = children[0]; const last = children.at(-1); if (first?.type === "text") first.value = first.value.trimStart(); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 0eeb7828f9cc2..4ce0dfb690e3b 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -94,10 +94,13 @@ type TraitsPersistence = | { threadRef?: ScopedThreadRef; draftId?: DraftId; + /** Runs after a change is written to the draft. */ + onDraftModelOptionsChange?: () => void; onModelOptionsChange?: never; } | { threadRef?: undefined; + onDraftModelOptionsChange?: never; onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void; }; @@ -338,6 +341,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ model, persistSticky: true, }); + persistence.onDraftModelOptionsChange?.(); }; const selected = getTraitsSectionVisibility({ provider, @@ -542,7 +546,6 @@ export const TraitsPicker = memo(function TraitsPicker({ } const { label: triggerLabel } = buildTraitsTriggerDisplay({ - provider, descriptors, primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, ultrathinkPromptControlled, diff --git a/apps/web/src/components/chat/TraitsSpeed.tsx b/apps/web/src/components/chat/TraitsSpeed.tsx index 6fdc71cc395cd..77b242827bf1c 100644 --- a/apps/web/src/components/chat/TraitsSpeed.tsx +++ b/apps/web/src/components/chat/TraitsSpeed.tsx @@ -6,7 +6,6 @@ import { cn } from "~/lib/utils"; import { ComposerControlIcon, type ComposerControlSize } from "./ComposerControl"; export function getTraitsSpeedDisplay( - provider: ProviderDriverKind, descriptor: ProviderOptionDescriptor, ): { label: string; speedIcon: "fast" | "ultrafast" | null } | null { if (descriptor.id === "fastMode" && descriptor.type === "boolean") { @@ -15,7 +14,7 @@ export function getTraitsSpeedDisplay( speedIcon: descriptor.currentValue === true ? "fast" : null, }; } - if (provider !== "codex" || descriptor.id !== "serviceTier" || descriptor.type !== "select") { + if (descriptor.id !== "serviceTier" || descriptor.type !== "select") { return null; } const currentValue = getProviderOptionCurrentValue(descriptor); diff --git a/apps/web/src/components/chat/ZoomableImage.tsx b/apps/web/src/components/chat/ZoomableImage.tsx index 0e8451300dc27..39f4705efad24 100644 --- a/apps/web/src/components/chat/ZoomableImage.tsx +++ b/apps/web/src/components/chat/ZoomableImage.tsx @@ -134,14 +134,15 @@ export function ZoomableImage({ const attachViewport = (viewport: HTMLDivElement) => { viewportRef.current = viewport; - // React's onWheel is passive and cannot stop the page from scrolling. + // Trackpad pinch arrives as a ctrlKey wheel; plain wheels pan through native overflow scrolling. + // React's onWheel is passive and cannot stop the browser's own page zoom. const wheel = (event: WheelEvent) => { - if (event.deltaY === 0) return; + if (!event.ctrlKey || event.deltaY === 0) return; event.preventDefault(); const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1); - changeZoom(zoom * Math.exp(-delta * (event.ctrlKey ? 0.01 : 0.002)), { + changeZoom(zoom * Math.exp(-delta * 0.01), { x: event.clientX, y: event.clientY, }); @@ -159,7 +160,7 @@ export function ZoomableImage({ ref={attachViewport} role="region" aria-label={`${name}, zoomable image`} - aria-description="Click to zoom in or return to fit. Scroll to zoom, drag to pan. Use Enter to toggle zoom, plus or minus to zoom, and 0 to fit." + aria-description="Click to zoom in or return to fit. Pinch or Ctrl+scroll to zoom, scroll or drag to pan. Use Enter to toggle zoom, plus or minus to zoom, and 0 to fit." tabIndex={0} className="max-w-[var(--media-width)] overflow-auto overscroll-contain rounded-lg bg-background shadow-2xl ring-1 ring-border/70 outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring" style={{ diff --git a/apps/web/src/components/chat/chatViewSend.ts b/apps/web/src/components/chat/chatViewSend.ts index 3a06955930e1a..3f87efa5ae634 100644 --- a/apps/web/src/components/chat/chatViewSend.ts +++ b/apps/web/src/components/chat/chatViewSend.ts @@ -16,7 +16,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { readLocalApi } from "../../localApi"; -import { getUploadedAttachments } from "../../lib/attachmentUploadQueue"; +import { awaitAttachmentUploads, getUploadedAttachments } from "../../lib/attachmentUploadQueue"; import { findRevertCheckpoint } from "../../session-logic"; import { type ChatMessage } from "../../types"; import { randomUUID } from "~/lib/utils"; @@ -321,7 +321,7 @@ export function resolveMultipleModelTargets(input: { /** * The attachments a turn sends: the server's upload records when it takes uploads, otherwise - * images inlined as data URLs. Rejects when an attachment cannot go out. + * images inlined as data URLs. Waits for in-flight uploads; rejects when one cannot go out. */ export function prepareTurnAttachments(input: { readonly environmentId: EnvironmentId; @@ -332,9 +332,10 @@ export function prepareTurnAttachments(input: { return Promise.all( input.attachments.map(async (attachment) => { if (usesAttachmentUploads) { + await awaitAttachmentUploads([attachment.id]); const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0]; if (!uploaded) { - throw new Error(`Attachment '${attachment.name}' did not finish uploading.`); + throw new Error(`Attachment '${attachment.name}' failed to upload.`); } return uploaded; } diff --git a/apps/web/src/components/chat/composerCommandKey.ts b/apps/web/src/components/chat/composerCommandKey.ts index 4a5c0e9abc0bc..ac5bba3475542 100644 --- a/apps/web/src/components/chat/composerCommandKey.ts +++ b/apps/web/src/components/chat/composerCommandKey.ts @@ -30,7 +30,7 @@ import type { Dispatch, RefObject, SetStateAction } from "react"; /** * The composer's key handling inside the editor: send shortcuts, the slash/mention menu, - * prompt history on ArrowUp/ArrowDown, plan-mode toggle, and list continuation. + * prompt history on ArrowUp/ArrowDown, plan-mode toggle, prompt suggestions on Tab, and list continuation. * Returns the editor's key handler; it reads the inputs captured by the render that built it. */ export function composerCommandKeyHandler(input: { @@ -82,6 +82,9 @@ export function composerCommandKeyHandler(input: { dispatchMode: ComposerDispatchMode, submissionIntent: ComposerSubmissionIntent, ) => void; + /** Predicted next prompt that Tab fills into an empty composer; Escape dismisses it. */ + readonly promptSuggestion: string | null; + readonly dismissPromptSuggestion: () => void; }) { const { keybindings, @@ -117,6 +120,8 @@ export function composerCommandKeyHandler(input: { readComposerSnapshot, applyPromptReplacement, submitComposer, + promptSuggestion, + dismissPromptSuggestion, } = input; // The composer persists across threads. A recall from thread A must not @@ -211,7 +216,12 @@ export function composerCommandKeyHandler(input: { const menuIsActive = !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); if (key === "Escape") { - if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; + if (event.isComposing || event.keyCode === 229) return false; + if (!menuIsActive && promptSuggestion !== null && promptRef.current.length === 0) { + dismissPromptSuggestion(); + return true; + } + if (!menuIsActive) return false; dismissComposerTrigger(trigger); composerMenuOpenRef.current = false; return true; @@ -232,6 +242,21 @@ export function composerCommandKeyHandler(input: { return true; } } + if ( + key === "Tab" && + promptSuggestion !== null && + promptRef.current.length === 0 && + !menuIsActive && + !event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + !event.isComposing && + event.keyCode !== 229 + ) { + // The typing guard must see this draft, so an approval that lands next is not answered with it. + return applyPromptReplacement(0, 0, promptSuggestion); + } if ((key === "ArrowUp" || key === "ArrowDown") && submissionIntent === null) { return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); } diff --git a/apps/web/src/components/chat/composerModelMentions.ts b/apps/web/src/components/chat/composerModelMentions.ts new file mode 100644 index 0000000000000..b0a8d0bd3b649 --- /dev/null +++ b/apps/web/src/components/chat/composerModelMentions.ts @@ -0,0 +1,43 @@ +import { rankComposerModelMentions } from "@t3tools/client-runtime/composer-model-mentions"; +import type { ProviderInstanceId } from "@t3tools/contracts"; + +import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; +import type { ComposerCommandItem } from "./ComposerCommandMenu"; +import { getDisplayModelName, type ModelEsque } from "./providerIconUtils"; + +type ComposerModelItem = Extract; + +export function matchComposerModelItems(input: { + readonly instanceEntries: ReadonlyArray; + readonly modelOptionsByInstance: ReadonlyMap>; + readonly query: string; +}): ComposerModelItem[] { + const candidates: Array<{ entry: ProviderInstanceEntry; option: ModelEsque }> = []; + for (const entry of input.instanceEntries) { + if (!isProviderInstancePickerReady(entry)) continue; + for (const option of input.modelOptionsByInstance.get(entry.instanceId) ?? []) { + // The picker hides legacy models by default; skip them so they don't fill the result cap. + if (option.isLegacy || option.isUnavailable) continue; + candidates.push({ entry, option }); + } + } + + return rankComposerModelMentions(candidates, input.query, ({ entry, option }) => ({ + name: option.name, + ...(option.shortName ? { shortName: option.shortName } : {}), + ...(option.subProvider ? { subProvider: option.subProvider } : {}), + driverKind: entry.driverKind, + providerDisplayName: entry.displayName, + })).map(({ entry, option }) => ({ + id: `model:${entry.instanceId}:${option.slug}`, + type: "model", + instanceId: entry.instanceId, + model: option.slug, + driverKind: entry.driverKind, + providerDisplayName: entry.displayName, + acpRegistryAgentId: entry.acpRegistryAgentId, + acpRegistryIconUrl: entry.acpRegistryIconUrl, + label: getDisplayModelName(option), + description: option.subProvider ?? entry.displayName, + })); +} diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 1dc51438f121a..6446f9115f516 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -52,6 +52,7 @@ type TraitsRenderInput = { instanceId?: ProviderInstanceId; threadRef?: ScopedThreadRef; draftId?: DraftId; + onDraftModelOptionsChange?: () => void; model: string; models: ReadonlyArray; modelOptions: ReadonlyArray | undefined; @@ -213,6 +214,7 @@ function renderTraitsControl( instanceId, threadRef, draftId, + onDraftModelOptionsChange, model, models, modelOptions, @@ -253,6 +255,7 @@ function renderTraitsControl( models={models} {...(threadRef ? { threadRef } : {})} {...(draftId ? { draftId } : {})} + {...(threadRef && onDraftModelOptionsChange ? { onDraftModelOptionsChange } : {})} model={model} modelOptions={resolvedModelOptions} reportedModelSelection={reportedModelSelection} diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index fd5cc6ac17062..f7aed151f2fcc 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -86,6 +86,31 @@ describe("queued message file edits", () => { expect(store.getComposerDraft(editTarget)).toBeNull(); }); + it("reports a removed queued attachment as an edit when the run leaves", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(editTarget, "Original message"); + expect( + recoverQueuedMessageEdit({ + editTarget, + threadTarget, + originalText: "Original message", + removedExistingAttachment: true, + }), + ).toBe("kept"); + expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Original message"); + store.clearComposerContent(threadTarget); + // An attachment-only message leaves nothing to put back in the composer. + expect( + recoverQueuedMessageEdit({ + editTarget, + threadTarget, + originalText: "", + removedExistingAttachment: true, + }), + ).toBe("discarded"); + expect(store.getComposerDraft(threadTarget)).toBeNull(); + }); + it("does not overwrite a separate draft when the queued run leaves", () => { const store = useComposerDraftStore.getState(); store.setPrompt(threadTarget, "Separate draft"); diff --git a/apps/web/src/components/chat/queuedMessageEdit.ts b/apps/web/src/components/chat/queuedMessageEdit.ts index 5d1d9fce27103..40a222a9f383a 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.ts @@ -12,14 +12,19 @@ export function recoverQueuedMessageEdit(input: { readonly editTarget: ComposerThreadTarget; readonly threadTarget: ComposerThreadTarget; readonly originalText: string; + readonly removedExistingAttachment?: boolean; }): "kept" | "discarded" | "clean" { const store = useComposerDraftStore.getState(); const draft = store.getComposerDraft(input.editTarget); const recovery = resolveQueuedEditExit({ editChanged: - draft !== null && - (draft.prompt !== input.originalText || draft.images.length > 0 || draft.files.length > 0), - threadDraftEmpty: !composerDraftHasUserContent(store.getComposerDraft(input.threadTarget)), + input.removedExistingAttachment === true || + (draft !== null && + (draft.prompt !== input.originalText || draft.images.length > 0 || draft.files.length > 0)), + // A cleared edit has nothing to put back, so it can only be discarded. + threadDraftEmpty: + composerDraftHasUserContent(draft) && + !composerDraftHasUserContent(store.getComposerDraft(input.threadTarget)), }); if (recovery === "kept") { store.moveComposerPromptAndImages(input.editTarget, input.threadTarget); diff --git a/apps/web/src/components/chat/timelineViewport.browser.test.tsx b/apps/web/src/components/chat/timelineViewport.browser.test.tsx new file mode 100644 index 0000000000000..c77ed433d0800 --- /dev/null +++ b/apps/web/src/components/chat/timelineViewport.browser.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { attachTimelineViewport } from "./timelineViewport"; + +describe("attachTimelineViewport", () => { + it("reports an upward scroll only while input is on an embedded frame", async () => { + // A plain scroller; the viewport needs no routes. + await using screen = await render( +
    +
    +
    +
    +