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/SECURITY.md b/.github/SECURITY.md index 69dc53dc4393f..56e3f03525e01 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,7 +1,7 @@ # Security policy Report security vulnerabilities affecting T3 Code or T3 Tools-operated infrastructure to -[security@ping.gg](mailto:security@ping.gg). Please do not disclose them publicly until we have had +[security@t3.codes](mailto:security@t3.codes). Please do not disclose them publicly until we have had a reasonable opportunity to investigate and remediate them. See the [full security policy](https://t3.codes/security-policy) for reporting details, scope, 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/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e9b7ae70a36ad..e27a8c90fdff8 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -13,6 +13,10 @@ inputs: description: Arguments for `vp install`, such as filters. Empty installs the whole workspace. required: false default: "" + save-pnpm-store: + description: Set to "false" when a concurrent job with the same install args saves the pnpm store + required: false + default: "true" runs: using: composite steps: @@ -123,10 +127,14 @@ runs: key: ${{ steps.pnpm_verified.outputs.cache-primary-key }} # A filtered install that started from the current full store adds nothing - # worth a second copy of it. + # worth a second copy of it. A stacked PR, based on a branch other than the + # default, saves a ~1 GB store only its own reruns can read, so it only restores. + # Concurrent jobs with the same install args reserve one key, so the losers + # compress the store for nothing; all but one pass save-pnpm-store: false. - name: Save pnpm store if: >- - inputs.install == 'true' && + inputs.install == 'true' && inputs.save-pnpm-store == 'true' && + (github.event_name != 'pull_request' || github.base_ref == github.event.repository.default_branch) && steps.pnpm_cache.outputs.cache-hit != 'true' && (steps.pnpm_store.outputs.namespace == 'full' || steps.pnpm_cache.outputs.cache-matched-key != format('pnpm-full-v1-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json'))) 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 6be0566711366..2bdcaced88f68 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,12 +136,20 @@ 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: | + # Fetch only the indexes these main and universe packages need. The full set is ~42 MB, + # which a slow mirror can stretch past the wait below. + sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \ + /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 -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config xvfb echo "$?" > "$RUNNER_TEMP/apt-status" ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & @@ -191,6 +200,60 @@ 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, + # which gates the whole run. Chromium comes from the cache Test Web shares. + - name: Resolve Playwright version + id: playwright_version + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium_cache + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} + + - name: Install Chromium for the built web client boot check + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' && steps.chromium_cache.outputs.cache-hit != 'true' }} + run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + run: | + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/web/node_modules/playwright/cli.js install-deps chromium + fi + + - name: Boot the built web client + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + timeout-minutes: 3 + run: node apps/web/scripts/verify-built-boot.ts + - name: Typecheck if: ${{ !cancelled() && steps.typecheck.outcome == 'success' }} timeout-minutes: 5 @@ -230,9 +293,16 @@ jobs: # the background while Vite+ installs; the step before the tests waits for it. - name: Start installing browser secret helper build libraries run: | + # Fetch only the indexes these main and universe packages need. The full set is ~42 MB, + # which a slow mirror can stretch past the wait below. + sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \ + /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 -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config echo "$?" > "$RUNNER_TEMP/apt-status" ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & @@ -256,7 +326,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 @@ -338,12 +408,6 @@ jobs: - name: Test run: vp run --filter @t3tools/web test - # Browser tests run source modules; this boots the production chunk graph. - - name: Boot the built web client - run: | - vp run --filter @t3tools/web build - node apps/web/scripts/verify-built-boot.ts - # apps/server sets `fileParallelism: false`, so its test files run strictly # one at a time. Sharding spreads them over separate runners instead of # separate workers, so no two server test files ever share a machine and the @@ -425,15 +489,7 @@ jobs: run: >- vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }} --uncached apps/server/integration/transferBudgetV2.integration.test.ts - --verify-hits 1 --report "${{ runner.temp }}/test-cache-${{ matrix.shard }}.json" apps/server - - - name: Upload test cache measurements - if: always() && steps.changes.outputs.test_server != 'false' - uses: actions/upload-artifact@v7 - with: - name: test-cache-${{ matrix.shard }} - path: ${{ runner.temp }}/test-cache-${{ matrix.shard }}.json - if-no-files-found: error + --verify-hits 1 apps/server # integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard # produces these files. Gating the upload on their presence keeps a @@ -540,7 +596,7 @@ jobs: needs: changes if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 @@ -591,7 +647,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 d7652bfaa0c45..4527f689457d9 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 81f26d6ce8f6e..0f1f78a8b6766 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: ubuntu-24.04 + # 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 @@ -215,9 +230,7 @@ jobs: git config user.name "${APP_SLUG}[bot]" git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" git add apps/mobile/app.config.ts - git commit --no-verify \ - -m "chore(mobile): bump app version to $RELEASE_VERSION" \ - -m "Co-authored-by: codex " + git commit --no-verify -m "chore(mobile): bump app version to $RELEASE_VERSION" git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" - name: Summarize manual build version @@ -240,13 +253,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 +351,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 +372,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 +431,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 +475,368 @@ 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 + + # A --path submission doesn't keep its archive on EAS; this is the only downloadable copy. + - name: Keep the AAB + if: steps.record.outputs.built != 'true' + uses: actions/upload-artifact@v7 + with: + name: android-store-aab-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/t3code.aab + retention-days: 14 + if-no-files-found: error + + - 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/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 8c121f3c31580..a66eb06a67eb3 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -88,7 +88,7 @@ jobs: axe --version - name: Install foldable simulator controls - run: npm install --global agent-device@0.21.25 + run: npm install --global agent-device@0.21.36 - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 3af4e6abeafe9..b227a2b8b12fe 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -77,6 +77,12 @@ on: required: false default: "" type: string + # Whether this job saves the workspace pnpm store. Release's two Macs + # install the same store on the same runner type, so only one saves it. + save_pnpm_store: + required: false + default: true + type: boolean version: required: true type: string @@ -160,6 +166,7 @@ jobs: uses: ./.github/actions/setup-ci-deps with: install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + save-pnpm-store: ${{ inputs.save_pnpm_store }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata @@ -203,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 @@ -408,6 +429,7 @@ jobs: run: command -v patchelf || { sudo apt-get update && sudo apt-get install -y patchelf; } - name: Build CLI archive + id: build_cli if: inputs.cli_archive shell: bash env: @@ -425,37 +447,52 @@ jobs: AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} run: | set -euo pipefail - if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then - key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" + extra_args=() + if [[ "${{ inputs.platform }}" == "mac" ]]; then + if [[ -n "${APPLE_API_KEY:-}" ]]; then + key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")" + trap 'rm -rf "$key_dir"' EXIT + key_path="$key_dir/AuthKey.p8" + (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path") + export APPLE_API_KEY="$key_path" + fi + # Older release refs lack deferred notarization and still wait here. + cli_help="$(node scripts/build-cli-archive.ts --help)" + if [[ "$cli_help" == *--notary-submission-file* ]]; then + extra_args=(--notary-submission-file "$RUNNER_TEMP/t3-cli-notary-submission") + echo "deferred_notarization=true" >> "$GITHUB_OUTPUT" + fi fi node scripts/build-cli-archive.ts \ --platform "${{ inputs.platform }}" \ --arch "${{ inputs.arch }}" \ --version "${{ inputs.version }}" \ --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli + --output-dir release-cli \ + ${extra_args[@]+"${extra_args[@]}"} - name: Smoke-test CLI archive if: inputs.cli_archive && inputs.verify_runner == '' shell: bash run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + # macOS uploads its archive after "Await CLI notarization" instead. - name: Upload CLI archive - if: inputs.cli_archive + if: inputs.cli_archive && inputs.platform != 'mac' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* if-no-files-found: error + # The archive is already gzip or zip; deflating it again saves under 2%. + compression-level: 0 - name: Install Linux desktop build libraries if: inputs.platform == 'linux' shell: bash run: | sudo apt-get update - sudo apt-get install -y libsecret-1-dev pkg-config + sudo apt-get install -y libsecret-1-dev pkg-config libgtk-3-0t64 libnss3 libasound2t64 libgbm1 if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then sudo apt-get install -y imagemagick fi @@ -618,6 +655,39 @@ jobs: vp run dist:desktop:artifact "${args[@]}" + # The CLI was submitted without waiting so Apple reviews it while the + # desktop builds. Notarization does not change the archive bytes, but + # nothing uploads them, so nothing publishes them, until Apple accepts. + # With signing configured, a missing submission id fails this step. + - name: Await CLI notarization + if: inputs.cli_archive && inputs.platform == 'mac' && steps.build_cli.outputs.deferred_notarization == 'true' + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + set -euo pipefail + if [[ -n "${APPLE_API_KEY:-}" ]]; then + key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")" + trap 'rm -rf "$key_dir"' EXIT + key_path="$key_dir/AuthKey.p8" + (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path") + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts await-notarization \ + --submission-file "$RUNNER_TEMP/t3-cli-notary-submission" + + - name: Upload notarized CLI archive + if: inputs.cli_archive && inputs.platform == 'mac' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli/* + if-no-files-found: error + # The archive is already gzip or zip; deflating it again saves under 2%. + compression-level: 0 + # Warm installs only touch the store index, so new content means a fetch. - name: Check staged install cache for new content id: stage_pnpm_changed @@ -761,6 +831,8 @@ jobs: name: desktop-${{ inputs.platform }}-${{ inputs.arch }} path: release-publish/* if-no-files-found: error + # Installers, zips and blockmaps are already compressed; deflating them again saves under 0.3%. + compression-level: 0 - name: Upload resource monitor uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33ad8b35566ab..bb750c5592f99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,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, @@ -517,6 +519,8 @@ jobs: if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 30 + outputs: + npm_launcher_artifact_id: ${{ steps.npm_launcher.outputs.artifact-id }} env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} @@ -565,22 +569,32 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - uses: ./.github/actions/setup-apt-mirrors + # Older release refs lack build:desktop:bundle and use the full build, + # which compiles the Linux browser secret helper and needs libsecret. + - name: Check bundle-only build support + id: bundle_build + run: | + script="$(node -p "require('./package.json').scripts['build:desktop:bundle'] ? 'build:desktop:bundle' : 'build:desktop'")" + echo "script=$script" >> "$GITHUB_OUTPUT" + + - if: steps.bundle_build.outputs.script == 'build:desktop' + uses: ./.github/actions/setup-apt-mirrors - # @t3tools/desktop#build compiles the Linux browser secret helper on a - # Linux host before packing, and that needs libsecret headers. - name: Install browser secret helper build libraries + if: steps.bundle_build.outputs.script == 'build:desktop' run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - # Runs t3#build (which depends on @t3tools/web#build) and - # @t3tools/desktop#build, so apps/server/dist holds the server bundle - # plus the web client and apps/desktop/dist-electron the Electron main. + # Runs t3#build (which depends on @t3tools/web#build) and the desktop + # bundle, so apps/server/dist holds the server bundle plus the web client + # and apps/desktop/dist-electron the Electron main. build:desktop:bundle + # skips the Linux browser secret helper, which this artifact does not + # carry; each Linux package job compiles its own per arch. # The release channel is already stable, nightly, or preview. Builds in # other repositories report "dev" telemetry. - name: Build JS bundle env: T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && needs.preflight.outputs.release_channel || 'dev' }} - run: vp run build:desktop + run: vp run ${{ steps.bundle_build.outputs.script }} # Two paths under apps/ so the artifact root is apps/; consumers download # into `apps` to restore both at their original locations. @@ -594,6 +608,22 @@ jobs: if-no-files-found: error retention-days: 1 + # publish_cli publishes this exact tarball, so the trusted publisher + # needs no workspace checkout or install. It is version-matched here. + - name: Build the npm launcher + run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages + + # GitHub allows a failed-jobs rerun for 30 days. publish_cli downloads + # this upload by its ID, which a publish_cli-only rerun keeps. + - name: Upload the npm launcher + id: npm_launcher + uses: actions/upload-artifact@v7 + with: + name: npm-launcher + path: npm-packages/t3.tgz + if-no-files-found: error + retention-days: 30 + # One job per platform and architecture (see release-desktop.yml), each # gated only on the JS bundle. Windows targets build on Linux and run their # native checks on a Windows runner of their own architecture. @@ -625,6 +655,8 @@ jobs: resource_key: darwin-arm64 cli_archive: true launch_check: true + # desktop_mac_x64 saves the shared pnpm store; this job finishes last. + save_pnpm_store: false desktop_mac_x64: name: Desktop macOS x64 @@ -796,6 +828,24 @@ jobs: with: install-args: --filter=@t3tools/scripts... + # Overlaps the artifact downloads. The wait below ends it before any + # workspace script runs, so the Play key lives only in this process. + # `exec` lets the runner's stop signals reach node directly. + - id: android_apk + name: Download nightly Android APK + if: needs.preflight.outputs.release_channel == 'nightly' + background: true + continue-on-error: true + timeout-minutes: 5 + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + PACKAGE_NAME: com.t3tools.t3code + APK_OUTPUT_DIR: ${{ runner.temp }}/android-apk + run: | + if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then + exec node .github/scripts/mirror-play-apk.cjs + fi + - name: Download all desktop artifacts uses: actions/download-artifact@v8 with: @@ -810,18 +860,6 @@ jobs: merge-multiple: true path: release-assets - - name: Download internal Android APK for Nightly - if: needs.preflight.outputs.release_channel == 'nightly' - continue-on-error: true - env: - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} - PACKAGE_NAME: com.t3tools.t3code - APK_OUTPUT_DIR: release-assets - run: | - if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then - node .github/scripts/mirror-play-apk.cjs - fi - # Installers verify archives against this file, so it is written from the # signed bytes that get uploaded, never from an earlier stage. - name: Write CLI archive checksums @@ -838,6 +876,10 @@ jobs: sha256sum "${archives[@]}" > SHA256SUMS cat SHA256SUMS + # Always runs, even after a failed step, and keeps that failure. + - name: Wait for nightly Android APK + wait: android_apk + # The desktop build omits the publish config for preview versions, so # electron-builder emits no updater manifests for them. Refuse to publish # if one shows up anyway: a `latest*.yml` or `nightly*.yml` on a preview @@ -903,10 +945,13 @@ jobs: echo 'release-assets/*.yml' fi echo 'release-assets/*.tar.gz' - # Only list APKs when Play has a processed internal build available. - for apk in release-assets/*.apk; do - [[ -f "$apk" ]] && echo "$apk" - done + # A failed or timed-out fetch can leave partial bytes, so list the APK + # only when this attempt's fetch succeeded and Play had one ready. + if [[ "${{ steps.android_apk.outcome }}" == "success" ]]; then + for apk in "$RUNNER_TEMP"/android-apk/*.apk; do + [[ -f "$apk" ]] && echo "$apk" + done + fi echo 'release-assets/SHA256SUMS' echo 'EOF' } >> "$GITHUB_OUTPUT" @@ -1000,7 +1045,7 @@ jobs: # nothing resolves unless asked for by name. publish_cli: name: Publish CLI to npm - needs: [preflight, release] + needs: [preflight, build_bundle, release] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} runs-on: ubuntu-24.04 # ubuntu-24.04 timeout-minutes: 15 @@ -1008,26 +1053,57 @@ jobs: contents: read id-token: write steps: + # Only what setup-ci-deps reads to install the project's Node and npm. - 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 dependencies + - name: Setup Node.js uses: ./.github/actions/setup-ci-deps with: - install-args: --filter=t3... --filter=@t3tools/scripts... + install: "false" - - name: Build the npm launcher - run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages + # Bind to build_bundle's exact upload: a name lookup takes the newest + # same-name artifact, and an empty ID downloads every artifact in the + # run. A later same-name upload hides this ID, so the download fails. + - name: Check the npm launcher artifact ID + env: + ARTIFACT_ID: ${{ needs.build_bundle.outputs.npm_launcher_artifact_id }} + run: | + if [[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::build_bundle gave no valid npm-launcher artifact ID: '$ARTIFACT_ID'." >&2 + exit 1 + fi + + - name: Download the npm launcher + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{ needs.build_bundle.outputs.npm_launcher_artifact_id }} + path: npm-packages + digest-mismatch: error + + - name: Check the npm launcher version + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + built="$(tar -xzOf npm-packages/t3.tgz package/package.json | jq -r .version)" + if [[ "$built" != "$RELEASE_VERSION" ]]; then + echo "::error::npm-launcher holds t3@$built, expected t3@$RELEASE_VERSION." >&2 + exit 1 + fi - name: Publish the npm launcher + env: + DIST_TAG: ${{ needs.preflight.outputs.cli_dist_tag }} run: | - if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose; then + # The ./ keeps npm from reading the path as a GitHub owner/repo spec. + if ! npm publish --access public --tag "$DIST_TAG" --provenance ./npm-packages/t3.tgz; then echo "::error::npm publish failed. Make sure package t3 has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 exit 1 fi @@ -1165,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+ @@ -1403,7 +1482,18 @@ jobs: git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml git commit --no-verify -m "chore(release): prepare $RELEASE_TAG" - git push origin HEAD:main + + # main moves while this job runs, so a rejected push rebases and retries. + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + exit 0 + fi + echo "Push rejected (attempt $attempt). Rebasing onto latest main." + git pull --rebase origin main + done + + echo "Could not push the version bump after 5 attempts." >&2 + exit 1 announce_discord: name: Announce release on Discord diff --git a/.gitignore b/.gitignore index f83e69ea4c778..5726a50263478 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ apps/mobile/.android-cache/ artifacts/app-store/screenshots/ artifacts/mobile-e2e/ artifacts/mobile-behavior/ +scripts/.e2e/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/.macroscope/ignore.md b/.macroscope/ignore.md index 66e5434c5ed13..7e67d1d059427 100644 --- a/.macroscope/ignore.md +++ b/.macroscope/ignore.md @@ -279,3 +279,7 @@ native/libghostty-vt/include/** # it needs. Regenerated by apps/mobile/scripts/generate-highlights.mts; review # that script and cpp/t3_highlights.c instead. apps/mobile/modules/t3-highlights/Vendor/** + +# Nitro bindings for T3Highlights. Regenerated by `vp run generate:nitro` in +# apps/mobile from modules/t3-highlights/src/T3Highlights.nitro.ts. +apps/mobile/modules/t3-highlights/nitrogen/generated/** 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..87f21a8a0a2f6 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. @@ -108,7 +108,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - [Testing](docs/internals/testing.md) carries these same test rules, plus review guidance, for review bots. Change both together. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. -- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. +- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. For a bug a user can reproduce in the web client, `vp run scenarios:prove` shows it failing on base and passing on the fix in the real app; see [Scenarios](docs/operations/scenarios.md). - The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely: - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. 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 b967c57f501f0..f2477c982afe8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.78", + "version": "0.0.79", "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:*", @@ -27,7 +27,7 @@ "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", "ffi-rs": "1.3.2", - "playwright-core": "1.60.0", + "playwright-core": "1.64.1", "react-grab": "^0.1.32", "tldts": "catalog:" }, @@ -37,6 +37,7 @@ "acorn": "8.16.0", "cross-env": "^10.1.0", "electron-builder": "26.16.1", + "jsdom": "^30.0.1", "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, diff --git a/apps/desktop/scripts/verify-preload-bundle.mjs b/apps/desktop/scripts/verify-preload-bundle.mjs index 3b5cb42dd2cc1..3c13eb3da9cb0 100644 --- a/apps/desktop/scripts/verify-preload-bundle.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.mjs @@ -106,17 +106,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: { - addEventListener: (_type, listener) => listener(), - }, - document: { - documentElement: { style: { setProperty: () => undefined } }, - }, + 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 106b626b6c9c2..b0aae435b3b42 100644 --- a/apps/desktop/scripts/verify-preload-bundle.test.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.test.mjs @@ -94,6 +94,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/ClerkTokenStore.test.ts b/apps/desktop/src/app/ClerkTokenStore.test.ts index b7ac0e38d5142..13f6bbf758a4f 100644 --- a/apps/desktop/src/app/ClerkTokenStore.test.ts +++ b/apps/desktop/src/app/ClerkTokenStore.test.ts @@ -3,9 +3,6 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; -import { vi } from "vite-plus/test"; - -vi.mock("node:fs", { spy: true }); import { openClerkTokenStorage, type ClerkTokenStorage } from "./ClerkTokenStore.ts"; @@ -91,24 +88,6 @@ describePosix("ClerkTokenStore", () => { }); }); - it("removes the legacy token file when the private copy already exists", async () => { - await withStateDir((stateDir) => { - const directory = NodePath.join(stateDir, "clerk"); - const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); - const tokenPath = NodePath.join(directory, "clerk-tokens.json"); - NodeFS.mkdirSync(directory, { mode: 0o755 }); - writeLoose(legacyPath, "stale-credential"); - writeLoose(tokenPath, "kept-credential"); - - openClerkTokenStorage(stateDir, () => idleStorage()); - - assert.equal(NodeFS.existsSync(legacyPath), false); - assert.equal(modeOf(directory), 0o700); - assert.equal(modeOf(tokenPath), 0o600); - assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "kept-credential"); - }); - }); - it("resolves a relative state directory before opening storage", async () => { await withStateDir(async (stateDir) => { const relativeStateDir = NodePath.relative(process.cwd(), stateDir); @@ -150,23 +129,6 @@ describePosix("ClerkTokenStore", () => { }); }); - it("migrates tokens when the legacy directory is on another filesystem", async () => { - await withStateDir((stateDir) => { - const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); - writeLoose(legacyPath, "existing-session"); - using _rename = vi.spyOn(NodeFS, "renameSync").mockImplementation(() => { - throw Object.assign(new Error("cross-device link"), { code: "EXDEV" }); - }); - - openClerkTokenStorage(stateDir, idleStorage); - - const tokenPath = NodePath.join(stateDir, "clerk", "clerk-tokens.json"); - assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "existing-session"); - assert.equal(modeOf(tokenPath), 0o600); - assert.equal(NodeFS.existsSync(legacyPath), false); - }); - }); - it("does not follow a symlinked clerk directory or token file", async () => { await withStateDir(async (stateDir) => { const outsideDirectory = NodePath.join(stateDir, "outside-dir"); diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index eb83f34770b5a..85e65af6e399c 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -2,10 +2,12 @@ import { assert, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as HttpClient from "effect/http/HttpClient"; +import { ChildProcessSpawner } from "effect/process"; import * as NetService from "@t3tools/shared/Net"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; @@ -69,14 +71,19 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as MacPermissions from "../permissions/MacPermissions.ts"; import * as PreviewManager from "../preview/Manager.ts"; -it.effect( - "starts the backend and window while Linux registration is stalled and cancels it on shutdown", - () => +it.effect.each([false, true])( + "stops backends together before flushing history and cancels Linux registration, stop defect=%s", + (stopDefect) => Effect.gen(function* () { const registrationStarted = yield* Deferred.make(); const releaseRegistration = yield* Deferred.make(); const registrationStopped = yield* Deferred.make(); const windowStarted = yield* Deferred.make(); + const firstStopStarted = yield* Deferred.make(); + const secondStopStarted = yield* Deferred.make(); + const releaseStops = yield* Deferred.make(); + const historyStarted = yield* Deferred.make(); + const releaseHistory = yield* Deferred.make(); const quitting = yield* Ref.make(false); const events: string[] = []; const record = (event: string) => @@ -112,6 +119,7 @@ it.effect( Effect.provideService(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(true), awaitDiscovery: Effect.succeed(true), + awaitUserShellDiscovery: Effect.succeed(true), updates: Stream.empty, }), Effect.provideService(DesktopAppSettings.DesktopAppSettings, { @@ -170,7 +178,21 @@ it.effect( start: record("backend"), currentConfig: Effect.succeedSome({ preflightFailure: Option.none() }), }), - list: Effect.succeed([]), + list: Effect.succeed([ + { + stop: () => + Deferred.succeed(firstStopStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseStops)), + Effect.andThen(stopDefect ? Effect.die("stop defect") : Effect.void), + ), + }, + { + stop: () => + Deferred.succeed(secondStopStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseStops)), + ), + }, + ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]), Effect.provideService(DesktopServerExposure.DesktopServerExposure, { configureFromSettings: () => Effect.succeed({ mode: "local-only" }), @@ -183,13 +205,17 @@ it.effect( start: Effect.void, } as unknown as DesktopAppActivation.DesktopAppActivation["Service"]), Effect.provideService(DesktopRendererHistory.DesktopRendererHistory, { - shutdown: Effect.void, + shutdown: Deferred.succeed(historyStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseHistory)), + Effect.asVoid, + ), } as DesktopRendererHistory.DesktopRendererHistory["Service"]), ); const app = yield* runApp.pipe( Effect.provide( Layer.mergeAll( Layer.mock(BrowserImport.BrowserImport, {}), + Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}), Layer.mock(DesktopBackendConfiguration.DesktopBackendConfiguration, {}), Layer.mock(DesktopCliCommand.DesktopCliCommand, {}), Layer.mock(DesktopClientSettings.DesktopClientSettings, {}), @@ -227,8 +253,19 @@ it.effect( yield* Deferred.await(windowStarted); assert.deepEqual(events, ["identity", "ready", "identity", "backend", "window"]); assert.isFalse(yield* Deferred.isDone(registrationStopped)); - yield* (yield* DesktopShutdown.DesktopShutdown).request; - yield* Fiber.join(app); + const shutdown = yield* DesktopShutdown.DesktopShutdown; + yield* shutdown.request; + yield* Deferred.await(firstStopStarted); + yield* Deferred.await(secondStopStarted); + assert.isFalse(yield* Deferred.isDone(historyStarted)); + assert.isFalse(yield* shutdown.isComplete); + yield* Deferred.succeed(releaseStops, undefined); + yield* Deferred.await(historyStarted); + assert.isFalse(yield* shutdown.isComplete); + yield* Deferred.succeed(releaseHistory, undefined); + const result = yield* Fiber.await(app); + assert.equal(Exit.isFailure(result), stopDefect); + assert.isTrue(yield* shutdown.isComplete); assert.isTrue(yield* Deferred.isDone(registrationStopped)); }).pipe(Effect.provide(DesktopShutdown.layer)), ); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 59c4bd288d8d3..7e9686867b149 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,6 +1,5 @@ import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -16,9 +15,11 @@ 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"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; @@ -160,7 +161,7 @@ const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( const instances = yield* pool.list; yield* Effect.forEach( instances, - (instance) => instance.stop({ timeout: Duration.seconds(5) }), + (instance) => instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }), { concurrency: "unbounded" }, ); }, @@ -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)"); @@ -361,6 +374,7 @@ const scopedProgram = Effect.scoped( stopAllPoolInstances().pipe( Effect.ensuring(rendererHistory.shutdown), Effect.ensuring(shutdown.markComplete), + Effect.withSpan("desktop.app.shutdown"), ), ); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index ace1c16a7d8ed..75cfc3e2a9e73 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -42,6 +42,7 @@ const layerElectronApp = (calls: ElectronAppCalls) => metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, preventAppSuspension: Effect.void, requestSingleInstanceLock: Effect.succeed(true), diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index ef9d2aad56795..206a143128a8e 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -116,6 +116,11 @@ export const make = Effect.gen(function* () { yield* electronApp.quit; return yield* Effect.interrupt; } + // Chromium opens these caches at the first event-loop turn, before ready. + yield* DesktopUserData.clearStaleGpuCaches({ + userDataPath, + electronVersion: process.versions.electron, + }); const pendingSecondInstances: Array = []; let handleSecondInstance = (argv: readonly string[]) => { 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/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 9ed85e4f99339..034d52f11f013 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -39,6 +39,7 @@ function layerElectronApp( metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, preventAppSuspension: Effect.void, requestSingleInstanceLock: Effect.succeed(true), diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts index 6a7473e2f1b1a..de3230de02624 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -49,6 +49,13 @@ export const make = FileSystem.makeNoop({ syncFs("makeDirectory", path, () => { NodeFS.mkdirSync(path, { recursive: options?.recursive ?? false }); }), + remove: (path, options) => + syncFs("remove", path, () => + NodeFS.rmSync(path, { + recursive: options?.recursive ?? false, + force: options?.force ?? false, + }), + ), writeFileString: (path, data, options) => syncFs("writeFileString", path, () => NodeFS.writeFileSync(path, data, { flag: options?.flag ?? "w" }), diff --git a/apps/desktop/src/app/DesktopUserData.test.ts b/apps/desktop/src/app/DesktopUserData.test.ts index 45cc0c1fdedc2..b23b0bd55f478 100644 --- a/apps/desktop/src/app/DesktopUserData.test.ts +++ b/apps/desktop/src/app/DesktopUserData.test.ts @@ -3,8 +3,9 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; -import { resolveUserDataPath } from "./DesktopUserData.ts"; +import { clearStaleGpuCaches, resolveUserDataPath } from "./DesktopUserData.ts"; it.effect.each(["darwin", "win32", "linux"] as const)( "uses an explicit isolated profile on %s without migrating installed state", @@ -78,3 +79,100 @@ it.effect.each(["t3code", "T3 Code (Alpha)"])( ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +const gpuCacheDirectories = [ + "GPUCache", + "GrShaderCache", + "ShaderCache", + "DawnGraphiteCache", + "DawnWebGPUCache", + "GPUPersistentCache", +]; + +it.effect.each([ + { profile: "written by Electron 44.10.0", marker: "44.10.0" }, + { profile: "with no recorded Electron version", marker: undefined }, +])("clears GPU caches $profile once when Electron 44.10.1 starts", ({ marker }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userDataPath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-gpu-cache-" }); + for (const name of gpuCacheDirectories) { + yield* fs.makeDirectory(path.join(userDataPath, name), { recursive: true }); + yield* fs.writeFileString(path.join(userDataPath, name, "data_0"), "44.10.0 shaders"); + } + yield* fs.makeDirectory(path.join(userDataPath, "Local Storage"), { recursive: true }); + yield* fs.writeFileString(path.join(userDataPath, "Local Storage", "LOCK"), "profile"); + yield* fs.writeFileString(path.join(userDataPath, "Local State"), "profile"); + if (marker !== undefined) { + yield* fs.writeFileString(path.join(userDataPath, "gpu-cache-electron-version"), marker); + } + + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + + const survivingCaches: Array = []; + for (const name of gpuCacheDirectories) { + if (yield* fs.exists(path.join(userDataPath, name))) survivingCaches.push(name); + } + assert.deepStrictEqual(survivingCaches, []); + assert.equal( + yield* fs.readFileString(path.join(userDataPath, "Local Storage", "LOCK")), + "profile", + ); + assert.equal(yield* fs.readFileString(path.join(userDataPath, "Local State")), "profile"); + + yield* fs.makeDirectory(path.join(userDataPath, "GPUCache")); + yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders"); + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + assert.equal( + yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")), + "44.10.1 shaders", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("keeps GPU caches built on the first launch of a fresh install", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const appData = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-fresh-" }); + const userDataPath = path.join(appData, "t3code-v2"); + + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + yield* fs.makeDirectory(path.join(userDataPath, "GPUCache")); + yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders"); + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + + assert.equal( + yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")), + "44.10.1 shaders", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("retries on the next launch when a GPU cache cannot be removed", () => + Effect.gen(function* () { + const writes: Array = []; + const fileSystem = FileSystem.makeNoop({ + remove: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: path, + }), + ), + writeFileString: (path) => + Effect.sync(() => { + writes.push(path); + }), + }); + + yield* clearStaleGpuCaches({ userDataPath: "/profile", electronVersion: "44.10.1" }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + + assert.deepStrictEqual(writes, []); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/desktop/src/app/DesktopUserData.ts b/apps/desktop/src/app/DesktopUserData.ts index b7d3f39055c8b..d672842ed2f8a 100644 --- a/apps/desktop/src/app/DesktopUserData.ts +++ b/apps/desktop/src/app/DesktopUserData.ts @@ -98,3 +98,43 @@ export const resolveUserDataPath = Effect.fn("desktop.userData.resolveUserDataPa return destinationPath; }, ); + +const GPU_CACHE_DIRECTORIES = [ + "GPUCache", + "GrShaderCache", + "ShaderCache", + "DawnGraphiteCache", + "DawnWebGPUCache", + "GPUPersistentCache", +]; + +// Keyed on Electron, not Chromium: Electron patches ship Chromium backports under the same Chrome version. +export const clearStaleGpuCaches = Effect.fn("desktop.userData.clearStaleGpuCaches")( + function* (input: { readonly userDataPath: string; readonly electronVersion: string }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const markerPath = path.join(input.userDataPath, "gpu-cache-electron-version"); + const cachedVersion = yield* fs.readFileString(markerPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.undefined, + ), + ); + if (cachedVersion?.trim() === input.electronVersion) return; + for (const name of GPU_CACHE_DIRECTORIES) { + yield* fs.remove(path.join(input.userDataPath, name), { recursive: true, force: true }); + } + yield* fs.makeDirectory(input.userDataPath, { recursive: true }); + yield* fs.writeFileString(markerPath, input.electronVersion); + }, + (effect, input) => + effect.pipe( + Effect.catch((error) => + Effect.logWarning("could not clear stale GPU caches", { + userDataPath: input.userDataPath, + electronVersion: input.electronVersion, + error: error.message, + }), + ), + ), +); 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/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index e4714673777d9..3126d12a2e938 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -421,7 +421,7 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("starts the configured backend and closes the scoped process on stop", () => + it.effect("stops the scoped process with output still pending", () => Effect.scoped( Effect.gen(function* () { let startCount = 0; @@ -458,6 +458,7 @@ describe("DesktopBackendManager", () => { yield* Scope.addFinalizer(scope, close); return makeProcess({ + stdout: Stream.never, exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), kill: () => close, }); @@ -720,4 +721,56 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("the quit stop keeps waiting for a backend that outlasts its deadline", () => + Effect.scoped( + Effect.gen(function* () { + const spawned = yield* Deferred.make(); + const teardownStarted = yield* Deferred.make(); + const teardownFinished = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + yield* Deferred.succeed(spawned, undefined); + yield* Scope.addFinalizer( + scope, + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Effect.sleep(Duration.seconds(10))), + Effect.andThen(Deferred.succeed(teardownFinished, undefined)), + Effect.asVoid, + ), + ); + return makeProcess({ + exitCode: Deferred.await(teardownFinished).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }); + }), + ), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + }); + yield* instance.start; + yield* Deferred.await(spawned); + + // App quit and the updater stop every backend with this deadline. + const quitDone = yield* Deferred.make(); + const quitFiber = yield* instance + .stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }) + .pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild); + + yield* Deferred.await(teardownStarted); + // Past the quit deadline, quit must still wait for the backend to exit. + yield* TestClock.adjust(Duration.seconds(9)); + assert.isFalse(yield* Deferred.isDone(quitDone)); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* Fiber.join(quitFiber); + assert.isTrue(yield* Deferred.isDone(teardownFinished)); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 2f5b57c4e3c95..d24b2f52f295c 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -41,6 +41,12 @@ const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); // Match the service launcher's grace period so backends can stop provider sessions. const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); +// Allow SIGKILL and the spawner exit wait after the SIGTERM grace so quit cannot +// orphan a backend whose shutdown hangs. +export const BACKEND_QUIT_STOP_TIMEOUT = Duration.sum( + DEFAULT_BACKEND_TERMINATE_GRACE, + Duration.seconds(2), +); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; // Three restarts without readiness is about 2 s of backoff: past a blip, early enough to help. @@ -234,6 +240,7 @@ export interface DesktopBackendInstance { readonly id: BackendInstanceId; readonly label: Effect.Effect; readonly start: Effect.Effect; + /** Timeout controls the warning; stop still waits for process teardown and exit. */ readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; readonly currentConfig: Effect.Effect>; readonly snapshot: Effect.Effect; @@ -301,21 +308,36 @@ const closeRun = ( run: ActiveBackendRun, parentScope: Scope.Scope, options?: { readonly timeout?: Duration.Duration }, -): Effect.Effect => { +) => { const waitForFiber = Option.match(run.fiber, { onNone: () => Effect.void, onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), }); - const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); + const close = Scope.close(run.scope, Exit.void).pipe( + Effect.withSpan("desktop.backendInstance.closeScope"), + Effect.andThen(waitForFiber.pipe(Effect.withSpan("desktop.backendInstance.awaitRun"))), + ); const timeout = options?.timeout; if (!timeout) { - return close.pipe(Effect.as(true)); + return close; } - return Effect.forkIn(close, parentScope).pipe( + return Effect.forkIn(close, parentScope, { startImmediately: true }).pipe( Effect.flatMap((closeFiber) => - Fiber.await(closeFiber).pipe(Effect.timeoutOption(timeout), Effect.map(Option.isSome)), + Fiber.await(closeFiber).pipe( + Effect.timeoutOption(timeout), + Effect.tap((completed) => + Option.isNone(completed) + ? logBackendProcessWarning("backend shutdown exceeded timeout; waiting for exit", { + timeoutMs: Duration.toMillis(timeout), + }) + : Effect.void, + ), + // Parent-scope interruption still finishes release; do not cancel sibling stops. + Effect.andThen(Fiber.await(closeFiber)), + Effect.asVoid, + ), ), ); }; @@ -567,6 +589,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped); const exit = yield* handle.exitCode.pipe( + Effect.withSpan("desktop.backendProcess.awaitExit"), Effect.map((exitCode) => ({ code: Option.some(exitCode), reason: `code=${exitCode}`, @@ -602,6 +625,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( }).pipe( Effect.timeout(options.outputDrainTimeout ?? DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT), Effect.ignore, + Effect.withSpan("desktop.backendProcess.drainOutput"), ); if (Exit.isFailure(exit)) { return yield* Effect.failCause(exit.cause); @@ -1088,10 +1112,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( onNone: () => Effect.void, // The enclosing operation owns the span for this inline handler. onSome: Effect.fnUntraced(function* (run) { - const closed = yield* closeRun(run, parentScope, options); - if (!closed) { - return; - } + yield* closeRun(run, parentScope, options); const cleanup = yield* mutex.withPermits(1)( SubscriptionRef.modify( state, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 2b2dd841ab53f..b4c7827ce1ae4 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -1,11 +1,17 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as TestClock from "effect/testing/TestClock"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { HttpClient } from "effect/http"; +import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as DesktopObservability from "../app/DesktopObservability.ts"; @@ -21,18 +27,105 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; import type { DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; -function layerPool(labelRef: Ref.Ref): Layer.Layer { +// Models the spawner's release for a backend that ignores SIGTERM, without OS signals. +const makeFakeBackendProcess = Effect.fn("test.makeFakeBackendProcess")(function* () { + const started = yield* Deferred.make(); + const exited = yield* Deferred.make(); + const signals = yield* Queue.unbounded<"SIGTERM" | "SIGKILL">(); + let running = false; + const killWait = Duration.seconds(1); + const exit = Effect.sync(() => { + running = false; + }).pipe(Effect.andThen(Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0))), Effect.asVoid); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") { + return yield* Effect.die("Expected a standard backend command"); + } + const terminateGrace = Duration.fromInputUnsafe(command.options.forceKillAfter ?? 0); + running = true; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* Queue.offer(signals, "SIGTERM"); + yield* Effect.sleep(terminateGrace); + yield* Queue.offer(signals, "SIGKILL"); + yield* Effect.sleep(killWait); + yield* exit; + }), + ); + yield* Deferred.succeed(started, undefined); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + exitCode: Deferred.await(exited), + isRunning: Effect.sync(() => running), + kill: () => exit, + unref: Effect.succeed(Effect.void), + }); + }), + ), + ); + return { + layer, + started: Deferred.await(started), + signals, + isRunning: Effect.sync(() => running), + killWait, + }; +}); + +const runningConfig = { + executablePath: "/electron", + args: [], + entryPath: "/server/bin.mjs", + cwd: "/server", + env: {}, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3774, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + bootstrapDelivery: "fd3", + extendEnv: true, + httpBaseUrl: new URL("http://127.0.0.1:3774"), + captureOutput: false, + preflightFailure: Option.none(), +} satisfies DesktopBackendStartConfig; + +function layerPool( + labelRef: Ref.Ref, + backend?: Effect.Success>, +): Layer.Layer { return DesktopBackendPool.layer.pipe( Layer.provideMerge( Layer.mergeAll( - FileSystem.layerNoop({}), - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), - ), + FileSystem.layerNoop({ exists: () => Effect.succeed(true) }), + backend?.layer ?? + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), + ), Layer.succeed( HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected HTTP request")), + HttpClient.make((request) => + backend === undefined + ? Effect.die("unexpected HTTP request") + : Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), + ), + ), ), Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { forInstance: () => @@ -90,6 +183,62 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer { + it.effect("unregister during pool quit does not interrupt sibling stops", () => + Effect.scoped( + Effect.gen(function* () { + const backend = yield* makeFakeBackendProcess(); + yield* Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); + const ready = yield* Deferred.make(); + const siblingStarted = yield* Deferred.make(); + const finishSibling = yield* Deferred.make(); + let siblingCompleted = false; + const instance = yield* pool.register({ + id, + label: Effect.succeed("WSL (Ubuntu)"), + configResolve: Effect.succeed(runningConfig), + onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* backend.started; + yield* Deferred.await(ready); + const quitFiber = yield* Effect.all( + [ + instance.stop({ timeout: Duration.seconds(5) }), + Deferred.succeed(siblingStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishSibling)), + Effect.andThen( + Effect.sync(() => { + siblingCompleted = true; + }), + ), + ), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Deferred.await(siblingStarted); + assert.equal(yield* Queue.take(backend.signals), "SIGTERM"); + const unregisterFiber = yield* pool.unregister(id).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(5)); + assert.equal(yield* Queue.take(backend.signals), "SIGKILL"); + assert.isUndefined(quitFiber.pollUnsafe()); + assert.isUndefined(unregisterFiber.pollUnsafe()); + assert.isTrue(yield* backend.isRunning); + yield* TestClock.adjust(backend.killWait); + yield* Fiber.join(unregisterFiber); + yield* Deferred.succeed(finishSibling, undefined); + yield* Fiber.join(quitFiber); + + assert.isTrue(siblingCompleted); + assert.isFalse(yield* backend.isRunning); + assert.isTrue(Option.isNone((yield* instance.snapshot).activePid)); + assert.isTrue(Option.isNone(yield* pool.get(id))); + }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), backend))); + }), + ), + ); + it.effect("unregister stops the registered instance", () => Effect.scoped( Effect.gen(function* () { @@ -99,27 +248,9 @@ describe("DesktopBackendPool", () => { id, label: Effect.succeed("WSL (Ubuntu)"), configResolve: Effect.succeed({ - executablePath: "/electron", - args: [], - entryPath: "/server/bin.mjs", - cwd: "/server", - env: {}, - bootstrap: { - mode: "desktop", - noBrowser: true, - port: 3774, - t3Home: "/tmp/t3", - host: "127.0.0.1", - desktopBootstrapToken: "token", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - bootstrapDelivery: "fd3", - extendEnv: true, - httpBaseUrl: new URL("http://127.0.0.1:3774"), - captureOutput: false, + ...runningConfig, preflightFailure: Option.some({ reason: "WSL starting", fatal: false }), - } satisfies DesktopBackendStartConfig), + }), }); yield* instance.start; assert.equal((yield* instance.snapshot).restartScheduled, true); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index c8d20e3fe9f36..6c2e8beccb2df 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -46,6 +46,7 @@ export class ElectronApp extends Context.Service< readonly name: Effect.Effect; /** Packaged builds ship only en-US; app.getLocale() and Intl defaults do not reflect the OS locale. */ readonly systemLocale: Effect.Effect; + readonly preferredSystemLanguages: Effect.Effect>; readonly whenReady: Effect.Effect; /** * Holds a power-save blocker for the scope's lifetime. The desktop app @@ -162,6 +163,7 @@ export const make = ElectronApp.of({ } return toSystemLocaleTag(identifier, hourCycle); }), + preferredSystemLanguages: Effect.sync(() => Electron.app.getPreferredSystemLanguages()), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; yield* Effect.tryPromise({ diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index d4338b45e280b..4ff3df6bf10f0 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -75,7 +75,10 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM const normalizedItem: ContextMenuItem = { id: sourceItem.id, - label: sourceItem.label, + label: + typeof sourceItem.detail === "string" && sourceItem.detail.length > 0 + ? `${sourceItem.label} (${sourceItem.detail})` + : sourceItem.label, destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 0e72a46146e0a..9864c19b0d0ab 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -54,6 +54,82 @@ 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); + + const urls = [ + "cursor://file/Users/me/repo/file.md:186", + "vscode://file/Users/me/repo/file.md:3:7", + "vscode-insiders://file/Users/me/repo/file.md", + "vscodium://file/C:/Users/me/repo/file.md:3", + ]; + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.forEach(urls, (url) => electronShell.openExternal(url)); + + assert.deepEqual(results, [true, true, true, true]); + assert.deepEqual( + openExternalMock.mock.calls, + urls.map((url) => [url]), + ); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("normalizes the scheme case of local editor file deep links", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("VSCode://file/Users/me/repo/file.md:3"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["vscode://file/Users/me/repo/file.md:3"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open editor URLs that are not absolute local file links", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.forEach( + [ + "cursor://user@file/Users/me/repo/file.md", + "cursor://:secret@file/Users/me/repo/file.md", + "cursor://file:8080/Users/me/repo/file.md", + "cursor://anysphere.cursor-deeplink/prompt?text=hi", + "vscode://vscode.git/clone?url=https%3A%2F%2Fexample.com%2Frepo.git", + "cursor://file", + "cursor://file/", + "cursor:file/Users/me/repo/file.md", + "foo://file/Users/me/repo/file.md", + "zed://file/Users/me/repo/file.md", + "jetbrains://file/Users/me/repo/file.md", + "javascript:alert(1)", + ], + (url) => electronShell.openExternal(url), + ); + + assert.deepEqual(results, Array(12).fill(false)); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open editor URLs that mix up link shapes", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index e32dba36a6dfa..e342361750f41 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,5 +1,6 @@ import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts"; import { + isLocalEditorFileUrl, REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor, type SystemSettingsPane, @@ -11,7 +12,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -// Remote editor deep links must reach the OS handler. +// Remote editor deep links and local editor file links must reach the OS handler. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -20,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 = /^\/(?:[^/@:]+@)?[^/@:]+\/.*$/; @@ -44,7 +49,10 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { try { const url = new URL(rawUrl); - return SAFE_WEB_PROTOCOLS.has(url.protocol) || isRemoteEditorUrl(url) + return SAFE_WEB_PROTOCOLS.has(url.protocol) || + APP_LAUNCH_URLS.has(url.href) || + isRemoteEditorUrl(url) || + isLocalEditorFileUrl(url) ? Option.some(url.href) : Option.none(); } catch { 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/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts new file mode 100644 index 0000000000000..45f63b951f366 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -0,0 +1,67 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { assert, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +vi.mock("electron", () => ({})); + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopIpc from "./DesktopIpc.ts"; + +it.effect("answers only the main window's top frame", () => + Effect.gen(function* () { + const mainFrame = {}; + const invoke = new Map(); + const sync = new Map(); + const ipc = yield* DesktopIpc.DesktopIpc.pipe( + Effect.provide( + DesktopIpc.layer({ + removeHandler: (channel) => void invoke.delete(channel), + handle: (channel, listener) => void invoke.set(channel, listener), + removeAllListeners: (channel) => void sync.delete(channel), + on: (channel, listener) => void sync.set(channel, listener), + }).pipe( + Layer.provide( + Layer.mock(ElectronWindow.ElectronWindow, { + main: Effect.succeedSome({ + webContents: { id: 1, mainFrame }, + } as unknown as Electron.BrowserWindow), + }), + ), + ), + ), + ); + yield* ipc.handle( + DesktopIpc.makeIpcMethod({ + channel: "token", + payload: Schema.Void, + result: Schema.String, + handler: () => Effect.succeed("secret"), + }), + ); + yield* ipc.handleSync({ channel: "token-sync", handler: () => Effect.succeed("secret") }); + + const senders = { + app: { sender: { id: 1 }, senderFrame: mainFrame }, + appSubframe: { sender: { id: 1 }, senderFrame: {} }, + previewGuest: { sender: { id: 2 }, senderFrame: {} }, + }; + const results: Record = {}; + for (const [name, sender] of Object.entries(senders)) { + const event = { ...sender, returnValue: "unset" as unknown }; + sync.get("token-sync")!(event); + results[name] = [ + yield* Effect.promise(() => + Promise.resolve(invoke.get("token")!(sender, undefined)).catch(() => "rejected"), + ), + event.returnValue, + ]; + } + assert.deepEqual(results, { + app: ["secret", "secret"], + appSubframe: ["rejected", undefined], + previewGuest: ["rejected", undefined], + }); + }).pipe(Effect.scoped), +); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index 354acc5a81552..69d4f70c15a0e 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -1,17 +1,23 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; + export interface DesktopIpcInvokeEvent { readonly sender: { readonly id: number }; + readonly senderFrame?: unknown; } -export interface DesktopIpcSyncEvent { +export interface DesktopIpcSyncEvent extends DesktopIpcInvokeEvent { returnValue: unknown; } +export type DesktopIpcSenderGuard = (event: DesktopIpcInvokeEvent) => Effect.Effect; + export type DesktopIpcHandleListener = ( event: DesktopIpcInvokeEvent, raw: unknown, @@ -52,6 +58,15 @@ export class DesktopIpcUnregistrationError extends Schema.TaggedError()( + "DesktopIpcUntrustedSenderError", + { channel: Schema.String }, +) { + override get message(): string { + return `Rejected ${this.channel} from a sender that is not the app window.`; + } +} + export const DesktopIpcError = Schema.Union([ DesktopIpcRegistrationError, DesktopIpcUnregistrationError, @@ -80,7 +95,11 @@ export class DesktopIpc extends Context.Service< } >()("@t3tools/desktop/ipc/DesktopIpc") {} -export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => +// ipcMain is shared by every webContents, including preview guests that load arbitrary pages. +export const make = ( + ipcMain: DesktopIpcMain, + isTrustedSender: DesktopIpcSenderGuard, +): DesktopIpc["Service"] => DesktopIpc.of({ handle: Effect.fn("desktop.ipc.registerInvoke")(function* ({ channel, @@ -98,6 +117,9 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => runPromise( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel }); + if (!(yield* isTrustedSender(event))) { + return yield* new DesktopIpcUntrustedSenderError({ channel }); + } return yield* handler(raw, event); }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), ), @@ -131,6 +153,11 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => event.returnValue = runSync( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel }); + // Throwing here would surface as an uncaught main-process exception. + if (!(yield* isTrustedSender(event))) { + yield* Effect.logWarning("Rejected sync IPC from an untrusted sender."); + return undefined; + } return yield* handler(); }).pipe( Effect.annotateLogs({ channel }), @@ -152,7 +179,28 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => }), }); -export const layer = (ipcMain: DesktopIpcMain) => Layer.succeed(DesktopIpc, make(ipcMain)); +export const layer = (ipcMain: DesktopIpcMain) => + Layer.effect( + DesktopIpc, + Effect.gen(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + return make(ipcMain, (event) => + Effect.map( + electronWindow.main, + (main) => + Option.isSome(main) && + main.value.webContents.id === event.sender.id && + main.value.webContents.mainFrame === event.senderFrame, + ), + ); + }), + ); + +export const layerTest = (ipcMain: DesktopIpcMain) => + Layer.succeed( + DesktopIpc, + make(ipcMain, () => Effect.succeed(true)), + ); export interface DesktopIpcMethodRegistration< Payload, diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index a46dbafab0f41..6a1b1c62e7471 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -6,6 +6,7 @@ import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts"; import { + getHostEnvironmentId, getLocalEnvironmentEnabled, setLocalEnvironmentEnabled, } from "./methods/localEnvironment.ts"; @@ -38,6 +39,7 @@ import { getLocalEnvironmentBearerToken, installRelayTelemetryEnabled, getSystemLocale, + getPreferredSystemLanguages, getWindowFullscreenState, openExternal, readMedia, @@ -50,6 +52,7 @@ import { pickProjectFavicon, pickThemeFiles, setTheme, + syncHtmlFullscreen, showContextMenu, } from "./methods/window.ts"; import { @@ -78,6 +81,7 @@ import { getCliCommandState, installCliCommand, uninstallCliCommand, + addCliCommandToPath, } from "./methods/cliCommand.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { @@ -94,10 +98,12 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getAppBranding); yield* installRelayTelemetryEnabled(); yield* ipc.handleSync(getSystemLocale); + yield* ipc.handleSync(getPreferredSystemLanguages); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); + yield* ipc.handle(getHostEnvironmentId); yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); @@ -149,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); @@ -158,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 05c72eb707830..33dd9e272df7d 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,12 +32,15 @@ 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"; +export const GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL = "desktop:get-preferred-system-languages"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; +export const GET_HOST_ENVIRONMENT_ID_CHANNEL = "desktop:get-host-environment-id"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; @@ -66,6 +70,7 @@ export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-beare export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; +export const SSH_PASSWORD_PROMPT_SETTLED_CHANNEL = "desktop:ssh-password-prompt-settled"; export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; @@ -90,6 +95,8 @@ export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor" export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; +export const PREVIEW_FIND_IN_PAGE_CHANNEL = "desktop:preview-find-in-page"; +export const PREVIEW_STOP_FIND_IN_PAGE_CHANNEL = "desktop:preview-stop-find-in-page"; export const PREVIEW_SET_SHOWN_CHANNEL = "desktop:preview-set-shown"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; @@ -114,6 +121,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; +export const PREVIEW_FIND_RESULT_CHANNEL = "desktop:preview-find-result"; export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; @@ -121,6 +129,9 @@ export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission" export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input"; +export const PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL = "desktop:preview-annotation-voice-event"; +export const PREVIEW_ANNOTATION_VOICE_STATE_CHANNEL = "desktop:preview-annotation-voice-state"; + export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback"; export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; 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/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts index 94bbf085a9921..36e89eb880c15 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -1,4 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -10,7 +13,11 @@ import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; -import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; +import { + getHostEnvironmentId, + getLocalEnvironmentEnabled, + setLocalEnvironmentEnabled, +} from "./localEnvironment.ts"; const layerUnusedLifecycleRuntime = Layer.mergeAll( DesktopShutdown.layer, @@ -27,6 +34,26 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll( ); describe("local environment IPC", () => { + it.effect("reads the shared home identity without creating one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped(); + const environment = DesktopEnvironment.DesktopEnvironment.of({ + path, + stateDir, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + const read = getHostEnvironmentId + .handler(undefined) + .pipe(Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment)); + assert.strictEqual(yield* read, null); + assert.isFalse(yield* fs.exists(path.join(stateDir, "environment-id"))); + yield* fs.writeFileString(path.join(stateDir, "environment-id"), " service-id\n"); + assert.strictEqual(yield* read, "service-id"); + yield* fs.writeFileString(path.join(stateDir, "environment-id"), "\n"); + assert.strictEqual(yield* read, null); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); it.effect("relaunches only when the setting changes and keeps other settings", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/localEnvironment.ts b/apps/desktop/src/ipc/methods/localEnvironment.ts index 74cccd04a0c58..3c5c2c63d77da 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as IpcChannels from "../channels.ts"; @@ -28,3 +30,18 @@ export const setLocalEnvironmentEnabled = makeIpcMethod({ } }), }); + +// A background service sharing this app's T3 home records its environment ID in the same state dir. +export const getHostEnvironmentId = makeIpcMethod({ + channel: IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.localEnvironment.getHostEnvironmentId")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem + .readFileString(environment.path.join(environment.stateDir, "environment-id")) + .pipe(Effect.orElseSucceed(() => "")); + return contents.trim() || null; + }), +}); diff --git a/apps/desktop/src/ipc/methods/notificationBadge.test.ts b/apps/desktop/src/ipc/methods/notificationBadge.test.ts index 7126b1cd31945..b3c85c9ead283 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.test.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -80,7 +80,7 @@ it.effect("validates IPC and clears on native focus, quit, and disposal", () => Effect.provideService(HostProcess.Platform, "linux"), Effect.provide([ ElectronApp.layer, - DesktopIpc.layer({ + DesktopIpc.layerTest({ handle: (channel, handler) => { handlers.set(channel, handler); }, diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 802f2f3fb18b6..bd7e44bec8307 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -42,14 +42,17 @@ describe("preview IPC methods", () => { Effect.scoped( Effect.gen(function* () { let listener: DesktopIpc.DesktopIpcHandleListener | undefined; - const ipc = DesktopIpc.make({ - removeHandler: vi.fn(), - handle: (_channel, registered) => { - listener = registered; + const ipc = DesktopIpc.make( + { + removeHandler: vi.fn(), + handle: (_channel, registered) => { + listener = registered; + }, + removeAllListeners: vi.fn(), + on: vi.fn(), }, - removeAllListeners: vi.fn(), - on: vi.fn(), - }); + () => Effect.succeed(true), + ); yield* ipc.handle(method); return yield* Effect.promise(async () => structuredClone(await listener!({ sender: { id: 7 } }, { tabId: "capture-tab" })), diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index c4c33f6b8dca8..ea291b66b3149 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,8 +1,11 @@ import { DesktopPreviewAnnotationSendEnabledInputSchema, DesktopPreviewAnnotationThemeInputSchema, + DesktopPreviewAnnotationVoiceStateSchema, + DesktopPreviewPickElementInputSchema, DesktopPreviewArtifactInputSchema, DesktopPreviewConfigInputSchema, + DesktopPreviewFindInPageInputSchema, DesktopPreviewNavigateInputSchema, DesktopPreviewRecordingArtifactSchema, DesktopPreviewRecordingSaveInputSchema, @@ -57,6 +60,9 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeOpenLinks((event) => electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event), ); + yield* manager.subscribeFindResults((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, event), + ); }); const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ @@ -114,6 +120,21 @@ const navigate = DesktopIpc.makeIpcMethod({ }), }); +const findInPage = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_FIND_IN_PAGE_CHANNEL, + payload: DesktopPreviewFindInPageInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.findInPage")(function* ({ + tabId, + text, + forward, + newSession, + }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.findInPage(tabId, text, { forward, newSession }); + }), +}); + const tabMethod = ( channel: string, name: string, @@ -167,6 +188,11 @@ const hardReload = tabMethod( "desktop.ipc.preview.hardReload", (manager, tabId) => manager.hardReload(tabId), ); +const stopFindInPage = tabMethod( + IpcChannels.PREVIEW_STOP_FIND_IN_PAGE_CHANNEL, + "desktop.ipc.preview.stopFindInPage", + (manager, tabId) => manager.stopFindInPage(tabId), +); const setZoomFactor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL, payload: DesktopPreviewSetZoomFactorInputSchema, @@ -363,11 +389,21 @@ const setAnnotationTheme = DesktopIpc.makeIpcMethod({ const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, - payload: DesktopPreviewTabInputSchema, + payload: DesktopPreviewPickElementInputSchema, result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema), - handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { + handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId, voice }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.pickElement(tabId, voice); + }), +}); + +const updateAnnotationVoice = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_ANNOTATION_VOICE_STATE_CHANNEL, + payload: DesktopPreviewAnnotationVoiceStateSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.updateAnnotationVoice")(function* (state) { const manager = yield* PreviewManager.PreviewManager; - return yield* manager.pickElement(tabId); + yield* manager.updateAnnotationVoice(state); }), }); @@ -428,6 +464,7 @@ const saveRecording = DesktopIpc.makeIpcMethod({ export const methods = [ setForwardedShortcuts, + updateAnnotationVoice, createTab, closeTab, registerWebview, @@ -439,6 +476,8 @@ export const methods = [ zoomOut, resetZoom, hardReload, + findInPage, + stopFindInPage, setColorScheme, setZoomFactor, setAudioMuted, diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 2ddc672891755..376f85d2baf9d 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 = { @@ -201,7 +202,7 @@ describe("installRelayTelemetryEnabled", () => { yield* installRelayTelemetryEnabled().pipe( Effect.provideService(HostProcess.Environment, env), Effect.provide( - Layer.mergeAll(DesktopIpc.layer(ipcMain), DesktopManagedPolicy.layerTest(policy)), + Layer.mergeAll(DesktopIpc.layerTest(ipcMain), DesktopManagedPolicy.layerTest(policy)), ), ); const event = { returnValue: undefined as unknown }; @@ -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 2833abca43859..e0eea5c74c9ed 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -106,6 +106,15 @@ export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getPreferredSystemLanguages = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL, + result: Schema.Array(Schema.String), + handler: Effect.fn("desktop.ipc.window.getPreferredSystemLanguages")(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + return yield* electronApp.preferredSystemLanguages; + }), +}); + export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, result: Schema.Boolean, @@ -442,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/main.ts b/apps/desktop/src/main.ts index d109518be15e4..e3971d7c74ea5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -139,7 +139,7 @@ const layerElectron = Layer.mergeAll( ElectronTheme.layer, ElectronUpdater.layer, ElectronWindow.layer, - DesktopIpc.layer(Electron.ipcMain), + DesktopIpc.layer(Electron.ipcMain).pipe(Layer.provide(ElectronWindow.layer)), ); const layerDesktopFoundation = Layer.mergeAll( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 72245779b9c7e..5bf05668b5a98 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,7 @@ import type { DesktopBridge, + DesktopPreviewAnnotationVoiceEvent, + DesktopPreviewFindResultEvent, DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, @@ -56,6 +58,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" && @@ -106,6 +120,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; }, + getPreferredSystemLanguages: () => { + const result: unknown = ipcRenderer.sendSync( + IpcChannels.GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL, + ); + return Array.isArray(result) ? result.filter((tag) => typeof tag === "string") : []; + }, getLocalEnvironmentBootstraps: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); if (!Array.isArray(result)) { @@ -119,6 +139,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, setLocalEnvironmentEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), + getHostEnvironmentId: () => ipcRenderer.invoke(IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), @@ -174,6 +195,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); }; }, + onSshPasswordPromptSettled: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, requestId: unknown) => { + if (typeof requestId !== "string") return; + listener(requestId); + }; + + ipcRenderer.on(IpcChannels.SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, wrappedListener); + }; + }, resolveSshPasswordPrompt: (requestId, password) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), @@ -277,6 +309,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) => { @@ -348,6 +381,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), + findInPage: (tabId, text, { forward, newSession }) => + ipcRenderer.invoke(IpcChannels.PREVIEW_FIND_IN_PAGE_CHANNEL, { + tabId, + text, + forward, + newSession, + }), + stopFindInPage: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_STOP_FIND_IN_PAGE_CHANNEL, { tabId }), setShown: (tabId, shown) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, { tabId, shown }), openDevTools: (tabId) => @@ -368,7 +410,20 @@ contextBridge.exposeInMainWorld("desktopBridge", { tabId, enabled, }), - pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), + annotationVoice: { + onEvent: (listener) => { + const onEvent = (_event: Electron.IpcRendererEvent, tabId: string, value: unknown) => { + if (typeof tabId !== "string" || typeof value !== "object" || value === null) return; + listener(tabId, value as DesktopPreviewAnnotationVoiceEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, onEvent); + return () => ipcRenderer.off(IpcChannels.PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, onEvent); + }, + update: (state) => + ipcRenderer.invoke(IpcChannels.PREVIEW_ANNOTATION_VOICE_STATE_CHANNEL, state), + }, + pickElement: (tabId, voice) => + ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId, voice }), cancelPickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), captureScreenshot: (tabId) => @@ -435,5 +490,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener); }, + onFindResult: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, findEvent: unknown) => { + if (typeof findEvent !== "object" || findEvent === null) return; + listener(findEvent as DesktopPreviewFindResultEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, wrappedListener); + }, }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview/Annotation.css b/apps/desktop/src/preview/Annotation.css index 0897aad4f86d5..3c5604e1b8cf3 100644 --- a/apps/desktop/src/preview/Annotation.css +++ b/apps/desktop/src/preview/Annotation.css @@ -36,6 +36,7 @@ --t3-popover-foreground: oklch(0.269 0 0); --t3-primary: oklch(0.488 0.217 264); --t3-primary-foreground: white; + --t3-destructive: oklch(0.577 0.245 27.325); --t3-muted: rgb(0 0 0 / 4%); --t3-muted-foreground: oklch(0.556 0 0); --t3-accent: rgb(0 0 0 / 4%); @@ -66,3 +67,8 @@ textarea:focus-visible { outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); outline-offset: -2px; } + +/* Utility display classes must not override the native hidden state. */ +[hidden] { + display: none !important; +} diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts index d02abc8ac6123..3b8e2b7000896 100644 --- a/apps/desktop/src/preview/AnnotationStyles.generated.ts +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. export const previewAnnotationStyles = - '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.visible {\n visibility: visible;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: -2px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n}\n'; + '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.visible {\n visibility: visible;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border-width: 0;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-5 {\n height: calc(var(--spacing) * 5);\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.h-full {\n height: 100%;\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-0\\.5 {\n width: calc(var(--spacing) * 0.5);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-7 {\n width: calc(var(--spacing) * 7);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.origin-center {\n transform-origin: center;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-between {\n justify-content: space-between;\n}\n.justify-center {\n justify-content: center;\n}\n.justify-end {\n justify-content: flex-end;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.gap-px {\n gap: 1px;\n}\n.truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-full {\n border-radius: calc(infinity * 1px);\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.pb-2 {\n padding-bottom: calc(var(--spacing) * 2);\n}\n.text-right {\n text-align: right;\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.tabular-nums {\n --tw-numeric-spacing: tabular-nums;\n font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n}\n.shadow {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-destructive: oklch(0.577 0.245 27.325);\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: -2px;\n}\n[hidden] {\n display: none !important;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ordinal {\n syntax: "*";\n inherits: false;\n}\n@property --tw-slashed-zero {\n syntax: "*";\n inherits: false;\n}\n@property --tw-numeric-figure {\n syntax: "*";\n inherits: false;\n}\n@property --tw-numeric-spacing {\n syntax: "*";\n inherits: false;\n}\n@property --tw-numeric-fraction {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-ordinal: initial;\n --tw-slashed-zero: initial;\n --tw-numeric-figure: initial;\n --tw-numeric-spacing: initial;\n --tw-numeric-fraction: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n}\n'; 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/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 7a339102f19a6..e68e52961fa60 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -16,6 +16,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ readonly setPermissionRequestHandler: ReturnType; readonly setPermissionCheckHandler: ReturnType; readonly setUserAgent: ReturnType; + readonly webRequest: { readonly onBeforeSendHeaders: ReturnType }; } >(), })); @@ -62,6 +63,7 @@ describe("BrowserSession", () => { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setUserAgent: vi.fn(), + webRequest: { onBeforeSendHeaders: vi.fn() }, }; sessions.set(partition, browserSession); return browserSession; @@ -115,10 +117,12 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () => + it.effect("keeps the native User-Agent except for the Electron token sent to Teams", () => Effect.gen(function* () { const nativeUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36"; + const teamsUserAgent = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Safari/537.36"; fromPartition.mockReset(); fromPartition.mockImplementation((partition: string) => { let userAgent = nativeUserAgent; @@ -131,6 +135,7 @@ describe("BrowserSession", () => { setUserAgent: vi.fn((next: string) => { userAgent = next; }), + webRequest: { onBeforeSendHeaders: vi.fn() }, }; sessions.set(partition, browserSession); return browserSession; @@ -143,6 +148,29 @@ describe("BrowserSession", () => { const browserSession = sessions.get(partition); assert.isDefined(browserSession); assert.strictEqual(browserSession.getUserAgent(), nativeUserAgent); + + // Teams redirects Electron to an error page before sign-in; every other host sees the native one. + const calls = browserSession.webRequest.onBeforeSendHeaders.mock.calls; + assert.strictEqual(calls.length, 1); + const [filter, listener] = calls[0] ?? []; + assert.deepStrictEqual(filter, { + urls: ["https://teams.cloud.microsoft/*", "https://teams.microsoft.com/*"], + }); + const send = (requestHeaders: Record) => { + let response: { requestHeaders?: Record } | undefined; + listener({ requestHeaders }, (next: typeof response) => { + response = next; + }); + return response; + }; + for (const key of ["User-Agent", "user-agent"]) { + const requestHeaders = { [key]: nativeUserAgent, Accept: "*/*" }; + assert.deepStrictEqual(send(requestHeaders), { + requestHeaders: { [key]: teamsUserAgent, Accept: "*/*" }, + }); + assert.strictEqual(requestHeaders[key], nativeUserAgent); + } + assert.deepStrictEqual(send({ Accept: "*/*" }), {}); }).pipe(Effect.provide(layer)), ); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index a780cacf11ea9..b2523ba62b973 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -55,6 +55,9 @@ const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => } }; +// Teams sends Electron User-Agents to an error page before sign-in. Electron allows one listener per session. +const TEAMS_URL_PATTERNS = ["https://teams.cloud.microsoft/*", "https://teams.microsoft.com/*"]; + const MAX_PROMPT_URL_LENGTH = 300; const truncateForPrompt = (text: string): string => @@ -342,6 +345,22 @@ export const make = Effect.gen(function* BrowserSessionMake() { ? clipboardReadDecisions.get(permissionOrigin(requestingOrigin) ?? "") === true : ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); + browserSession.webRequest.onBeforeSendHeaders( + { urls: TEAMS_URL_PATTERNS }, + ({ requestHeaders }, callback) => { + const userAgent = Object.entries(requestHeaders).find( + ([name]) => name.toLowerCase() === "user-agent", + ); + if (userAgent === undefined) { + callback({}); + return; + } + const [name, value] = userAgent; + callback({ + requestHeaders: { ...requestHeaders, [name]: value.replace(/ Electron\/\S+/, "") }, + }); + }, + ); const next = new Map(sessions); next.set(partition, browserSession); return [browserSession, next] as const; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index b2a3886f87668..a40450643215f 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -34,6 +34,8 @@ const command = ( const makeDebuggee = ( options: { readonly deferFocus?: boolean; + /** The guest is still loading its first page. */ + readonly loading?: boolean; readonly refuse?: (method: string, sessionId: string | undefined) => boolean; } = {}, ) => { @@ -69,10 +71,13 @@ const makeDebuggee = ( sendWaiters.shift()?.(); }), }); + let destroyed = false; const webContents = Object.assign(new NodeEvents.EventEmitter(), { getURL: () => "http://localhost/", getTitle: () => "Page", getUserAgent: () => "Electron", + isLoadingMainFrame: () => options.loading === true, + isDestroyed: () => destroyed, }); return { tab: { @@ -81,6 +86,12 @@ const makeDebuggee = ( }, emit: (method: string, params: unknown) => emitter.emit("message", {}, method, params, ""), navigate: () => webContents.emit("did-navigate"), + domReady: () => webContents.emit("dom-ready"), + destroy: () => { + destroyed = true; + }, + failLoad: (code: number) => + webContents.emit("did-fail-load", {}, code, "Failed", "http://localhost/", true), /** Ids of the commands the debugger received, in order. */ sent, focusEmulation, @@ -144,6 +155,71 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("announces a guest still loading its first page only once that page is up", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const loading = makeDebuggee({ loading: true }); + const other = { ...key, tabId: "tab-2" }; + const reader = yield* takeEvents(host, 3).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + host.attach(key, loading.tab); + host.setShown(key, true); + host.attach(other, makeDebuggee().tab); + loading.domReady(); + + expect(yield* Fiber.join(reader)).toEqual([ + { type: "attached", ...other }, + { type: "attached", ...key }, + { type: "shown", ...key, shown: true }, + ]); + }), + ); + + it.effect( + "keeps a loading guest unannounced across a replaced navigation and a new backend", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const loading = makeDebuggee({ loading: true }); + const other = { ...key, tabId: "tab-2" }; + host.attach(key, loading.tab); + loading.failLoad(-3); + const reader = yield* takeEvents(host, 2).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + host.attach(other, makeDebuggee().tab); + loading.domReady(); + + expect(yield* Fiber.join(reader)).toEqual([ + { type: "attached", ...other }, + { type: "attached", ...key }, + ]); + }), + ); + + it.effect( + "announces a first page that never comes up, but never a tab closed or destroyed first", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const closed = { ...key, tabId: "tab-2" }; + const destroyed = { ...key, tabId: "tab-3" }; + const destroyedGuest = makeDebuggee({ loading: true }); + const reader = yield* takeEvents(host, 1).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + host.attach(closed, makeDebuggee({ loading: true }).tab); + host.detach(closed); + host.attach(destroyed, destroyedGuest.tab); + destroyedGuest.destroy(); + host.attach(key, makeDebuggee({ loading: true }).tab); + yield* TestClock.adjust("5 seconds"); + + expect(yield* Fiber.join(reader)).toEqual([{ type: "attached", ...key }]); + }), + ); + it.effect("re-sends shown to a backend that starts later and when a tab attaches again", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; @@ -531,20 +607,21 @@ describe("DesktopBrowserHost", () => { params: { sessionId: "t3-preview-page-1" }, }); expect(yield* Queue.take(replies)).toBe(3); - expect(painting).toEqual([true, false]); + expect(painting).toEqual([true]); // The agent's screenshot waits its turn; painting lets the abandoned one finish first. yield* command(host, 4, "Page.captureScreenshot"); yield* settle; - expect(painting).toEqual([true, false, true]); + expect(painting).toEqual([true]); debuggee.release(); expect(yield* Queue.take(replies)).toBe(2); yield* settle; expect(debuggee.sent).toEqual([undefined, 4]); + expect(painting).toEqual([true]); debuggee.release(); expect(yield* Queue.take(replies)).toBe(4); yield* settle; - expect(painting).toEqual([true, false, true, false]); + expect(painting).toEqual([true, false]); }).pipe(Effect.scoped), ); @@ -569,14 +646,19 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(2); yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); yield* settle; - expect(painting).toEqual([true, false, true]); + expect(painting).toEqual([true]); yield* raw({ id: 4, method: "Target.detachFromTarget", params: { sessionId: "t3-preview-page-1" }, }); expect(yield* Queue.take(replies)).toBe(4); - expect(painting).toEqual([true, false, true, false]); + + // The agent's answers late; the viewer's is dropped unsent, so nothing is left to paint. + debuggee.release(); + yield* settle; + expect(debuggee.sent).toEqual([1]); + expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); }).pipe(Effect.scoped), @@ -606,7 +688,7 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); - it.effect("stops painting for a screenshot still queued when the agent's turn ends", () => + it.effect("holds no focus for a screenshot still queued when the agent's turn ends", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); @@ -621,15 +703,18 @@ describe("DesktopBrowserHost", () => { yield* settle; yield* turn(false); yield* settle; - expect(painting).toEqual([true, false]); + expect(painting).toEqual([true]); - // The first answers late; the second still goes in turn, but holds nothing. + // The first answers late; the second still goes in turn, painting but holding no focus. debuggee.releaseFirst(); yield* settle; expect(debuggee.sent).toEqual([1, 2]); - expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); + expect(painting).toEqual([true]); + debuggee.release(); + yield* settle; + expect(painting).toEqual([true, false]); }).pipe(Effect.scoped), ); @@ -876,7 +961,7 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); - it.effect("stops painting for a screenshot Chromium never answers", () => + it.effect("stops painting for a screenshot Chromium never answers once its document goes", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); @@ -885,32 +970,34 @@ describe("DesktopBrowserHost", () => { const turn = (active: boolean) => host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); - // The server gave up on it, and the agent's turn ends. + // The server gave up on it, and the agent's turn ends; Chromium still holds the capture. yield* turn(true); yield* command(host, 1, "Page.captureScreenshot"); yield* settle; yield* turn(false); yield* settle; - expect(painting).toEqual([true, false]); + expect(painting).toEqual([true]); // The page loads a new document, which also frees the screenshot turn. debuggee.navigate(); + expect(painting).toEqual([true, false]); yield* command(host, 2, "Page.captureScreenshot"); yield* settle; debuggee.navigate(); yield* settle; expect(painting).toEqual([true, false, true, false]); - // The server releases the relay. + // The server releases the relay; Chromium still holds the capture. yield* command(host, 3, "Page.captureScreenshot"); yield* settle; yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* settle; - expect(painting).toEqual([true, false, true, false, true, false]); + expect(painting).toEqual([true, false, true, false, true]); // The guest detaches; a replacement starts with no screenshot pending. const replacement = makeDebuggee(); host.attach(key, replacement.tab); + expect(painting).toEqual([true, false, true, false, true, false]); yield* command(host, 4, "Page.captureScreenshot"); yield* settle; host.detach(key); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 80a23417b7163..2cda717a891b7 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -4,10 +4,12 @@ import { DesktopBrowserCommand, DesktopBrowserEvent, type DesktopBrowserEvent as DesktopBrowserEventType, + type PreviewAutomationLoadError, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as NodePath from "node:path"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -26,6 +28,9 @@ const AGENT_INPUT_COMMAND = const AGENT_INPUT_METHOD = /^(?:Input\.|Page\.navigate$|Runtime\.(?:evaluate|callFunctionOn)$)/; const AGENT_DOWNLOAD_WINDOW_MS = 5_000; const FOCUS_IDLE = "5 seconds"; +/** Well inside the server's 10 s DESKTOP_ATTACH_TIMEOUT, for a first page that never reports it is up. */ +const FIRST_PAGE_TIMEOUT = "5 seconds"; +const ERR_ABORTED = -3; export interface DesktopBrowserTabKey { readonly threadId: string; @@ -44,7 +49,7 @@ interface AttachedTab { readonly debuggee: DesktopBrowserTabDebugger; relay: CdpRelayConnection | null; screenshotTurn: object | null; - /** Each holds focus and painting while `held` and whoever queued it still waits on it. */ + /** Each keeps the guest painting while whoever queued it still waits, and focused while `held`. */ readonly waitingScreenshots: Array<{ readonly start: () => void; readonly live: () => boolean; @@ -57,10 +62,9 @@ interface AttachedTab { focusChange: Promise | null; focusUses: number; /** Each pending command, by the extra session waiting on it; the main connection's have none. */ - readonly focusPending: Map< - object, - { readonly owner: string | undefined; readonly paints: boolean } - >; + readonly focusPending: Map; + /** Screenshots Chromium was sent and has not answered; each keeps its wake lock until then. */ + readonly capturing: Set; /** Whether the client keeps the guest painting, as published on `painting`. */ painting: boolean; /** @@ -78,6 +82,8 @@ interface AttachedTab { sessionId: string, ) => void; readonly onNewDocument: () => void; + /** Stops waiting for the first page; set until the tab is announced. */ + stopWaiting: (() => void) | null; } export class DesktopBrowserHost extends Context.Service< @@ -87,6 +93,7 @@ export class DesktopBrowserHost extends Context.Service< readonly handleCommandLine: (line: string) => Effect.Effect; readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; readonly detach: (key: DesktopBrowserTabKey) => void; + readonly loadFailed: (key: DesktopBrowserTabKey, failure: PreviewAutomationLoadError) => void; readonly setShown: (key: DesktopBrowserTabKey, shown: boolean | undefined) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; @@ -142,7 +149,7 @@ export const make = Effect.gen(function* () { tab: AttachedTab, relay: CdpRelayConnection, ownerLive: () => boolean, - /** Sends only while `current` holds; holds focus and painting only while `held`. */ + /** Sends only while `current` holds; holds focus only while `held`. */ capture: (current: () => boolean, held: boolean) => Promise, ) => new Promise((resolve, reject) => { @@ -230,38 +237,42 @@ export const make = Effect.gen(function* () { const active = tabs.get(keyOf(tab.key)) === tab && (tab.screencasts.size > 0 || - waitingHolds(tab) || - [...tab.focusPending.values()].some((hold) => hold.paints)); + tab.capturing.size > 0 || + tab.waitingScreenshots.some((waiting) => waiting.live())); if (tab.painting === active) return; tab.painting = active; runFork(PubSub.publish(painting, { key: tab.key, active })); }; /** A command may wait on the page's next frame, so the guest stays focused until it settles. */ - const holdFocus = ( - tab: AttachedTab, - method: string, - owner: string | undefined, - work: () => Promise, - ) => { + const holdFocus = (tab: AttachedTab, owner: string | undefined, work: () => Promise) => { const pending = {}; - tab.focusPending.set(pending, { owner, paints: method === "Page.captureScreenshot" }); + tab.focusPending.set(pending, owner); tab.focusUses += 1; setFocusEmulation(tab, true); releaseFocusWhenIdle(tab); - syncPainting(tab); return work().finally(() => { if (!tab.focusPending.delete(pending)) return; tab.focusUses += 1; releaseFocusWhenIdle(tab); + }); + }; + + /** Chromium answers a capture, and frees its wake lock, only once the guest paints a frame. */ + const paintUntilAnswered = (tab: AttachedTab, send: () => Promise) => { + const sent = {}; + tab.capturing.add(sent); + syncPainting(tab); + return send().finally(() => { + tab.capturing.delete(sent); syncPainting(tab); }); }; /** * Chromium can leave a command unanswered, so the screenshot guard's release points - * also end every hold: a released relay has no one waiting, and a new document answers - * nothing pending from the old one. A released relay's screencast has no viewer to stop it. + * also end every focus hold: a released relay has no one waiting, and a new document + * answers nothing pending from the old one. A released relay's screencast has no viewer. */ const dropFocusHolds = (tab: AttachedTab) => { tab.focusPending.clear(); @@ -279,8 +290,8 @@ export const make = Effect.gen(function* () { /** A detached session, such as a closed viewer, waits on nothing it sent. */ const dropSessionHolds = (tab: AttachedTab, owner: string) => { tab.screencasts.delete(owner); - for (const [pending, hold] of tab.focusPending) { - if (hold.owner === owner) tab.focusPending.delete(pending); + for (const [pending, holder] of tab.focusPending) { + if (holder === owner) tab.focusPending.delete(pending); } releaseFocusWhenIdle(tab); syncPainting(tab); @@ -288,7 +299,7 @@ export const make = Effect.gen(function* () { /** * A command the server stopped waiting on, such as a timed-out screenshot or evaluation, - * may never settle; the turn's end ends every hold. The agent also sends on its own extra + * may never settle; the turn's end ends every focus hold. The agent also sends on its own extra * session, so this cannot spare other sessions; a viewer's screencast still holds. */ const endAgentTurn = (threadId: string) => { @@ -296,7 +307,7 @@ export const make = Effect.gen(function* () { for (const tab of tabs.values()) { if (tab.key.threadId !== threadId) continue; tab.agentUsed = false; - // Still sent in turn, so the queue keeps moving, but no longer kept painting. + // Still sent in turn, so the queue keeps moving, but no longer kept focused. for (const waiting of tab.waitingScreenshots) waiting.held = false; dropFocusHolds(tab); } @@ -317,8 +328,10 @@ export const make = Effect.gen(function* () { if (sessionId === undefined && method === "Page.startScreencast") { tab.screencasts.add(owner); if (owner !== undefined) streamingOwners.add(owner); + syncPainting(tab); } else if (sessionId === undefined && method === "Page.stopScreencast") { tab.screencasts.delete(owner); + syncPainting(tab); } // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. if ( @@ -331,9 +344,11 @@ export const make = Effect.gen(function* () { const forward = (current?: () => boolean) => { const change = tab.focusChange; const run = () => - tab.relay === relay && (current === undefined || current()) - ? send(method, params, sessionId) - : Promise.reject(new Error("The page this screenshot was for has moved on.")); + tab.relay !== relay || (current !== undefined && !current()) + ? Promise.reject(new Error("The page this screenshot was for has moved on.")) + : current === undefined + ? send(method, params, sessionId) + : paintUntilAnswered(tab, () => send(method, params, sessionId)); return change === null ? run() : change.then(run); }; return method === "Page.captureScreenshot" @@ -342,17 +357,17 @@ export const make = Effect.gen(function* () { relay, () => owner === undefined || !detachedOwners.has(owner), (current, held) => - held ? holdFocus(tab, method, owner, () => forward(current)) : forward(current), + held ? holdFocus(tab, owner, () => forward(current)) : forward(current), ) : method === "Page.startScreencast" - ? holdFocus(tab, method, owner, forward).catch((cause: unknown) => { + ? holdFocus(tab, owner, forward).catch((cause: unknown) => { // A viewer can outlive a refused stream; nothing is streaming for it. if (sessionId === undefined && tab.relay === relay) tab.screencasts.delete(owner); releaseFocusWhenIdle(tab); syncPainting(tab); throw cause; }) - : holdFocus(tab, method, owner, forward); + : holdFocus(tab, owner, forward); }, sessionDetached: (owner) => { detachedOwners.add(owner); @@ -413,11 +428,13 @@ export const make = Effect.gen(function* () { const tab = tabs.get(id); if (!tab) return; tabs.delete(id); + const announced = tab.stopWaiting === null; + tab.stopWaiting?.(); tab.relay = null; tab.debuggee.debugger.off("message", tab.onMessage); tab.debuggee.webContents.off("did-navigate", tab.onNewDocument); syncPainting(tab); - emit({ type: "detached", ...key }); + if (announced) emit({ type: "detached", ...key }); }; const attach = (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => { @@ -437,6 +454,7 @@ export const make = Effect.gen(function* () { focusChange: null, focusUses: 0, focusPending: new Map(), + capturing: new Set(), painting: false, agentUsed: false, focusReleasing: false, @@ -451,17 +469,58 @@ export const make = Effect.gen(function* () { // A screenshot pending when the page loads a new document, a reload included, can // stay unanswered. A crashed page lands here once it reloads, well after its renderer died. onNewDocument: () => { - // Before the next screenshot starts, so its own hold survives. + // Before the next screenshot starts, so its own holds survive. + tab.capturing.clear(); dropFocusHolds(tab); if (tab.screenshotTurn !== null) nextScreenshot(tab); }, + stopWaiting: null, }; tabs.set(id, tab); debuggee.debugger.on("message", tab.onMessage); debuggee.webContents.on("did-navigate", tab.onNewDocument); - emit({ type: "attached", ...key }); - const shown = shownTabs.get(id); - if (shown !== undefined) emit({ type: "shown", ...key, shown }); + const announce = () => { + emit({ type: "attached", ...key }); + const shown = shownTabs.get(id); + if (shown !== undefined) emit({ type: "shown", ...key, shown }); + }; + if (!debuggee.webContents.isLoadingMainFrame()) { + announce(); + return; + } + // Electron does not pause a guest for the debugger, so Playwright connecting during its first + // commit builds its utility world in the discarded document and every wait on it hangs. + const { webContents } = debuggee; + // ERR_ABORTED is a newer navigation replacing this one, whose commit is still to come. + const onFailLoad = ( + _event: Electron.Event, + code: number, + _description: string, + _url: string, + isMainFrame: boolean, + ) => { + if (isMainFrame && code !== ERR_ABORTED) ready(); + }; + const ready = () => { + if (tab.stopWaiting === null) return; + // The manager's cleanup for a destroyed guest can still be queued behind another tab's attach. + if (webContents.isDestroyed()) return detach(key); + tab.stopWaiting(); + announce(); + }; + const timeout = runFork( + Effect.sleep(FIRST_PAGE_TIMEOUT).pipe(Effect.andThen(Effect.sync(ready))), + ); + tab.stopWaiting = () => { + tab.stopWaiting = null; + webContents.off("dom-ready", ready); + webContents.off("did-stop-loading", ready); + webContents.off("did-fail-load", onFailLoad); + runFork(Fiber.interrupt(timeout)); + }; + webContents.on("dom-ready", ready); + webContents.on("did-stop-loading", ready); + webContents.on("did-fail-load", onFailLoad); }; const setShown = (key: DesktopBrowserTabKey, shown: boolean | undefined) => { @@ -472,7 +531,11 @@ export const make = Effect.gen(function* () { } if (shownTabs.get(id) === shown) return; shownTabs.set(id, shown); - if (tabs.has(id)) emit({ type: "shown", ...key, shown }); + if (tabs.get(id)?.stopWaiting === null) emit({ type: "shown", ...key, shown }); + }; + + const loadFailed: DesktopBrowserHost["Service"]["loadFailed"] = (key, failure) => { + if (tabs.has(keyOf(key))) emit({ type: "loadFailed", ...key, ...failure }); }; const handleCommandLine = (line: string) => @@ -511,6 +574,8 @@ export const make = Effect.gen(function* () { [...tabs.values()], (tab) => { dropRelay(tab); + // Announced once its first page is up. + if (tab.stopWaiting !== null) return Effect.void; const shown = shownTabs.get(keyOf(tab.key)); return PubSub.publish(outbox, { type: "attached", ...tab.key }).pipe( Effect.andThen( @@ -538,6 +603,7 @@ export const make = Effect.gen(function* () { handleCommandLine, attach, detach, + loadFailed, setShown, placeDownload, humanStartedDownload, diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 29011a3db7e01..fbf90a38c4eb9 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -6,6 +6,7 @@ export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; +export const PAGE_UNHANDLED_SHORTCUT_CHANNEL = "preview:page-unhandled-shortcut"; export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor"; export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; export const RECORDING_KEY_CHANNEL = "preview:recording-key"; @@ -14,3 +15,5 @@ export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; +export const ANNOTATION_VOICE_EVENT_CHANNEL = "preview:annotation-voice-event"; +export const ANNOTATION_VOICE_STATE_CHANNEL = "preview:annotation-voice-state"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 32b9ad23f9c93..a178edeb9753f 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,12 +1,15 @@ 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 { + DesktopPreviewFindResultEvent, DesktopPreviewRecordingInputEvent, PreviewAnnotationPayload, PreviewAnnotationRect, } 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 +172,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 +575,104 @@ 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: () => {}, + loadFailed: () => {}, + 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], @@ -712,6 +814,135 @@ describe("PreviewManager", () => { ), ); + effectIt.effect.each([ + ["darwin", true], + ["linux", false], + ["win32", false], + ] as const)( + "lets the page and its iframes claim the find shortcut before the host opens find on %s", + ([platform, usesMeta]) => + withManager( + (manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const send = vi.fn(); + const mainFrame = { processId: 1, routingId: 1 }; + Object.assign(preview.webContents, { + hostWebContents: { isDestroyed: () => false, send }, + mainFrame, + focusedFrame: mainFrame, + }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_page_find"); + yield* manager.registerWebview("tab_page_find", 42); + yield* manager.setForwardedShortcuts([ + { command: "preview.find", shortcut: parseKeybindingShortcut("mod+f")! }, + ]); + const beforeInput = preview.listeners.get("before-input-event")!; + const findKey = { + type: "keyDown", + key: "f", + code: "KeyF", + meta: usesMeta, + control: !usesMeta, + }; + const preventDefault = vi.fn(); + + beforeInput( + { preventDefault } as never, + { ...findKey, shift: false, alt: false } as never, + ); + expect(preventDefault).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + + const ipcOn = (preview.webContents as Electron.WebContents).ipc.on as ReturnType< + typeof vi.fn + >; + const pageUnhandledShortcut = ipcOn.mock.calls.find( + ([channel]) => channel === "preview:page-unhandled-shortcut", + )![1]; + pageUnhandledShortcut( + {}, + { + key: "f", + code: "KeyF", + metaKey: usesMeta, + ctrlKey: !usesMeta, + shiftKey: false, + altKey: false, + }, + ); + expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", "preview.find"); + + Object.assign(preview.webContents, { focusedFrame: { processId: 1, routingId: 2 } }); + beforeInput( + { preventDefault } as never, + { ...findKey, shift: false, alt: false } as never, + ); + expect(preventDefault).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledOnce(); + + Object.assign(preview.webContents, { focusedFrame: mainFrame }); + yield* manager.setForwardedShortcuts([ + { command: "preview.find", shortcut: parseKeybindingShortcut("ctrl+alt+8")! }, + ]); + pageUnhandledShortcut( + {}, + { + key: "[", + code: "Digit8", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: true, + }, + ); + expect(send).toHaveBeenCalledTimes(usesMeta ? 2 : 1); + }), + platform, + ), + ); + + effectIt.effect("searches the guest page and reports its match counts", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const findInPage = vi.fn(); + const stopFindInPage = vi.fn(); + Object.assign(preview.webContents, { findInPage, stopFindInPage }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_find"); + yield* manager.registerWebview("tab_find", 42); + const reported = yield* Deferred.make(); + yield* manager.subscribeFindResults((event) => Deferred.succeed(reported, event)); + + yield* manager.findInPage("tab_find", "needle", { forward: true, newSession: true }); + expect(findInPage).toHaveBeenCalledExactlyOnceWith("needle", { + forward: true, + findNext: true, + }); + + preview.listeners.get("found-in-page")!( + {} as never, + { + requestId: 1, + activeMatchOrdinal: 2, + matches: 5, + finalUpdate: true, + } as never, + ); + expect(yield* Deferred.await(reported)).toEqual({ + tabId: "tab_find", + activeMatchOrdinal: 2, + matches: 5, + }); + + yield* manager.stopFindInPage("tab_find"); + expect(stopFindInPage).toHaveBeenCalledExactlyOnceWith("clearSelection"); + }), + ), + ); + effectIt.effect("isolates failed state listeners and continues delivery", () => { const loggedErrors: Array = []; const logger = Logger.make(({ message }) => { @@ -824,6 +1055,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* () { @@ -1839,6 +2117,97 @@ describe("PreviewManager", () => { }).pipe(Effect.provide(managerLayer()), Effect.scoped), ); + effectIt.effect("reports early and subsequent load failures for the current guest", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.DesktopBrowserHost; + const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; + const lines = yield* Queue.unbounded(); + yield* host.events.pipe( + Stream.runForEach((line) => Queue.offer(lines, new TextDecoder().decode(line))), + Effect.forkScoped({ startImmediately: true }), + ); + const makeGuest = (id: number) => { + const listeners = new Map void>>(); + const wc = Object.assign( + makeTestPreviewWebContents( + async () => ({ + toJPEG: () => Buffer.from("jpeg"), + getSize: () => ({ width: 100, height: 80 }), + }), + id, + ), + { + isDevToolsOpened: () => false, + getUserAgent: () => "Electron", + once: vi.fn(), + on: (event: string, listener: (...args: unknown[]) => void) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + }, + }, + ); + const emit = (event: string, ...args: unknown[]) => { + for (const listener of listeners.get(event) ?? []) listener(...args); + }; + return { + wc, + emit, + fail: (url: string) => + emit("did-fail-load", {}, -202, "ERR_CERT_AUTHORITY_INVALID", url, true), + }; + }; + const initial = makeGuest(42); + const replacement = makeGuest(43); + const manager = yield* PreviewManager.PreviewManager; + const statuses: PreviewManager.PreviewNavStatus[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + statuses.push(state.navStatus); + }), + ); + yield* manager.createTab("tab_1", { serverTab }); + yield* manager.prepareWebview(initial.wc); + yield* manager.prepareWebview(replacement.wc); + initial.fail("https://first.local/"); + initial.emit("did-start-navigation", { isMainFrame: false, isSameDocument: false }); + initial.emit("did-start-loading"); + replacement.fail("https://second.local/"); + const failure = (url: string) => ({ + type: "loadFailed", + ...serverTab, + url, + code: -202, + description: "ERR_CERT_AUTHORITY_INVALID", + }); + + fromId.mockReturnValue(initial.wc); + yield* manager.registerWebview("tab_1", 42); + expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "attached", ...serverTab }); + expect(JSON.parse(yield* Queue.take(lines))).toEqual(failure("https://first.local/")); + + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_1", 43); + expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "detached", ...serverTab }); + expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "attached", ...serverTab }); + expect(JSON.parse(yield* Queue.take(lines))).toEqual(failure("https://second.local/")); + + // A callback queued by the old guest must not report a failure for its replacement. + initial.fail("https://stale.local/"); + replacement.fail("https://current.local/"); + expect(JSON.parse(yield* Queue.take(lines))).toEqual(failure("https://current.local/")); + expect(statuses).not.toContainEqual(expect.objectContaining({ url: "https://stale.local/" })); + + const retried = makeGuest(44); + yield* manager.prepareWebview(retried.wc); + retried.fail("https://retry.local/"); + retried.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }); + fromId.mockReturnValue(retried.wc); + yield* manager.registerWebview("tab_1", 44); + expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "detached", ...serverTab }); + expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "attached", ...serverTab }); + expect(statuses.at(-1)?.kind).toBe("Success"); + }).pipe(Effect.provide(managerLayer()), Effect.scoped), + ); + effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => withManager((manager) => Effect.gen(function* () { @@ -2443,6 +2812,70 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("delivers annotation voice state only to the pick session that owns it", () => + withManager((manager) => + Effect.gen(function* () { + const firstReady = yield* Deferred.make(); + const secondReady = yield* Deferred.make(); + let registrations = 0; + const guest = { + ...makeTestPreviewWebContents(async () => ({ + toPNG: () => Buffer.from("unused"), + toJPEG: () => Buffer.from("unused"), + getSize: () => ({ width: 1, height: 1 }), + })), + isFocused: () => true, + focus: vi.fn(), + send: (channel: string, ...args: unknown[]) => { + webviewSend(channel, ...args); + if (channel === "preview:start-pick") + Deferred.doneUnsafe(registrations++ === 0 ? firstReady : secondReady, Effect.void); + }, + once: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn(), removeListener: vi.fn() }, + }; + fromId.mockReturnValue(guest as never); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const first = yield* manager + .pickElement("tab_1", { sessionId: "first", shortcuts: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstReady); + const state = { + tabId: "tab_1", + sessionId: "first", + available: true, + phase: "recording" as const, + status: "Recording", + errorAction: null, + preview: null, + freezesEditor: true, + blocksSubmission: true, + level: 0, + shortcutLabel: "Ctrl+D", + draft: { text: "Tighten spacing", cursor: 15 }, + }; + yield* manager.updateAnnotationVoice(state); + expect(webviewSend).toHaveBeenCalledWith("preview:annotation-voice-state", state); + const second = yield* manager + .pickElement("tab_1", { sessionId: "second", shortcuts: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(secondReady); + expect(yield* Fiber.join(first)).toBeNull(); + webviewSend.mockClear(); + yield* manager.updateAnnotationVoice(state); + expect(webviewSend).not.toHaveBeenCalled(); + yield* manager.updateAnnotationVoice({ ...state, sessionId: "second" }); + expect(webviewSend).toHaveBeenCalledOnce(); + yield* manager.cancelPickElement("tab_1"); + expect(yield* Fiber.join(second)).toBeNull(); + webviewSend.mockClear(); + yield* manager.updateAnnotationVoice({ ...state, sessionId: "second" }); + expect(webviewSend).not.toHaveBeenCalled(); + }), + ), + ); + effectIt.effect("keeps annotation send grants isolated and updates an active pick", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 81ffa8aa8cd94..7f39a92bdb769 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1,12 +1,18 @@ +// @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, DesktopPreviewRecordingInputSchema, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, } from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, + DesktopPreviewAnnotationVoiceConfig, + DesktopPreviewAnnotationVoiceState, DesktopPreviewColorScheme, DesktopPreviewFavicon, + DesktopPreviewFindResultEvent, DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, PreviewAnnotationPayload, @@ -18,6 +24,7 @@ import type { DesktopPreviewScreenshotArtifact, DesktopPreviewTabDefaults, PreviewForwardedShortcut, + PreviewAutomationLoadError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings"; @@ -45,6 +52,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"; @@ -56,18 +64,25 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; -import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; +import { + MENU_ACTION_CHANNEL, + PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, + PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, +} from "../ipc/channels.ts"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; import * as PreviewPasskeys from "./Passkeys.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_SEND_ENABLED_CHANNEL, + ANNOTATION_VOICE_EVENT_CHANNEL, + ANNOTATION_VOICE_STATE_CHANNEL, ANNOTATION_THEME_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, @@ -396,9 +411,14 @@ interface PendingRecording { } interface PickSession { + readonly voiceSessionId?: string | undefined; readonly cancel: Effect.Effect; } +const decodeAnnotationVoiceEvent = Schema.decodeUnknownOption( + DesktopPreviewAnnotationVoiceEventSchema, +); + interface BrowserControlSession { readonly webContentsId: number; // Retain the attached Debugger to prevent GC crashes (electron/electron#53376); wc.debugger throws after destruction. @@ -420,6 +440,23 @@ type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; +type FindResultListener = (event: DesktopPreviewFindResultEvent) => Effect.Effect; + +const PAGE_FIRST_FORWARDED_COMMANDS: ReadonlySet = new Set([ + "preview.find", +]); + +const decodePageShortcutKeys = Schema.decodeUnknownOption( + Schema.Struct({ + key: Schema.String, + code: Schema.String, + metaKey: Schema.Boolean, + ctrlKey: Schema.Boolean, + shiftKey: Schema.Boolean, + altKey: Schema.Boolean, + }), +); + // about:blank inherits relaxed guest preferences; external editor schemes must reach the OS handler. const POPUP_PROTOCOLS = new Set(["http:", "https:"]); @@ -540,6 +577,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); const openLinkListenersRef = yield* Ref.make>(new Set()); + const findResultListenersRef = yield* Ref.make>(new Set()); const recordingInputListenersRef = yield* Ref.make>( new Set(), ); @@ -567,6 +605,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { readonly semaphore: Semaphore.Semaphore; users: number } >(); const tabLifecycleGenerations = new Map(); + // A guest can fail its first load before registerWebview installs its tab listeners. + const guestLoadFailures = new Map(); const attempt = (errorContext: PreviewOperationContext, evaluate: () => A) => Effect.try({ @@ -805,7 +845,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link", + eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link" | "find-result", tabId: string, delivery: () => Effect.Effect, ) => @@ -1361,16 +1401,39 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function validatedUrl: string, isMainFrame: boolean, ): void => { - if (code === -3 || !isMainFrame) return; + if (code === -3 || !isMainFrame || wc.isDestroyed()) return; + const url = validatedUrl || wc.getURL(); runFork( - update(tabId, { - navStatus: { - kind: "LoadFailed", - url: validatedUrl || wc.getURL(), - title: wc.getTitle(), - code, - description, - }, + Effect.gen(function* () { + const updatedAt = yield* currentIso; + const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if ( + !current || + current.webContentsId !== wc.id || + wc.isDestroyed() || + webContents.fromId(wc.id) !== wc + ) { + return [Option.none(), tabs] as const; + } + const state: PreviewTabState = { + ...current, + navStatus: { kind: "LoadFailed", url, title: wc.getTitle(), code, description }, + updatedAt, + }; + return [ + Option.some(state), + replaceMap(tabs, (copy) => copy.set(tabId, state)), + ] as const; + }); + if (Option.isNone(next)) return; + const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (current?.webContentsId !== wc.id || current.navStatus !== next.value.navStatus) + return; + if (current.serverTab) { + browserHost.loadFailed(current.serverTab, { url, code, description }); + } + yield* emitIfCurrent(tabId, next.value); }), ); }; @@ -1408,6 +1471,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); }; + // Chromium splits one search across several updates (the ordinal and the + // final count can land separately), so every update is forwarded. + const foundInPage = (_event: unknown, result: Electron.Result): void => { + runFork( + emitFindResult({ + tabId, + activeMatchOrdinal: result.activeMatchOrdinal, + matches: result.matches, + }), + ); + }; const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; @@ -1444,6 +1518,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function syncMenuShortcuts(window.webContents, input); }); }; + const findForwardedShortcut = (keys: Parameters[0]) => + !( + hostPlatform !== "darwin" && + keys.ctrlKey && + keys.altKey && + /^(?:[^a-zA-Z0-9]|Dead)$/u.test(keys.key) + ) && + forwardedShortcuts.find(({ shortcut }) => + matchesKeybindingShortcut( + keys, + shortcut, + hostPlatform === "darwin" ? "MacIntel" : hostPlatform, + ), + ); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { syncMenuShortcuts(wc, input); const host = wc.hostWebContents; @@ -1453,27 +1541,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function host && !host.isDestroyed() && holdsKeyboardFocus(wc) && - !( - hostPlatform !== "darwin" && - input.control && - input.alt && - /^(?:[^a-zA-Z0-9]|Dead)$/u.test(input.key) - ) && - forwardedShortcuts.find(({ shortcut }) => - matchesKeybindingShortcut( - { - key: input.key, - code: input.code, - metaKey: input.meta, - ctrlKey: input.control, - shiftKey: input.shift, - altKey: input.alt, - }, - shortcut, - hostPlatform === "darwin" ? "MacIntel" : hostPlatform, - ), - ); + findForwardedShortcut({ + key: input.key, + code: input.code, + metaKey: input.meta, + ctrlKey: input.control, + shiftKey: input.shift, + altKey: input.alt, + }); if (forwarded && host) { + // The page keeps these keys. Only the main-frame preload can report one it + // left unhandled, so a focused iframe (e.g. Google Docs' input) keeps its own find. + if (PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; event.preventDefault(); if (!input.isAutoRepeat) { if ( @@ -1498,6 +1577,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } }; + const pageUnhandledShortcut = (_event: unknown, rawKeys?: unknown): void => { + const host = wc.hostWebContents; + if (!host || host.isDestroyed() || !holdsKeyboardFocus(wc)) return; + const keys = decodePageShortcutKeys(rawKeys); + if (Option.isNone(keys)) return; + const forwarded = findForwardedShortcut(keys.value); + if (!forwarded || !PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; + host.send(MENU_ACTION_CHANNEL, forwarded.command); + }; yield* Scope.addFinalizer( scope, attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { @@ -1512,11 +1600,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("dom-ready", restoreRecordingCursor); wc.off("did-fail-load", failed as never); wc.off("audio-state-changed", audioStateChanged); + wc.off("found-in-page", foundInPage as never); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + wc.ipc.off(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys(); }).pipe(Effect.ignore), ); @@ -1533,9 +1623,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("dom-ready", restoreRecordingCursor); wc.on("did-fail-load", failed as never); wc.on("audio-state-changed", audioStateChanged); + wc.on("found-in-page", foundInPage as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + wc.ipc.on(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys = passkeys.attachGuest(wc); wc.setWindowOpenHandler((details) => { const action = previewWindowOpenAction(details); @@ -1821,11 +1913,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; } const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; + const failure = guestLoadFailures.get(webContentsId); const { favicon: _favicon, ...currentWithoutFavicon } = current; const next: PreviewTabState = { ...currentWithoutFavicon, webContentsId, - navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, + navStatus: + failure && (pendingUrl === null || pendingUrl === failure.url) + ? { kind: "LoadFailed", title: wc.getTitle(), ...failure } + : pendingUrl === null + ? computeNavStatus(wc) + : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), canGoForward: wc.navigationHistory.canGoForward(), audible: attachedAudible, @@ -1893,7 +1991,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { yield* rendererHistory.register(wc, { surface: "preview" }); const webContentsId = wc.id; + wc.on("did-start-navigation", (event) => { + if (event.isMainFrame && !event.isSameDocument) guestLoadFailures.delete(webContentsId); + }); + wc.on("did-fail-load", (_event, code, description, validatedUrl, isMainFrame) => { + if (code === -3 || !isMainFrame) return; + guestLoadFailures.set(webContentsId, { + url: validatedUrl || wc.getURL(), + code, + description, + }); + }); wc.once("destroyed", () => { + guestLoadFailures.delete(webContentsId); runFork(hideDestroyedGuest(webContentsId)); runFork(detachControlSession(webContentsId)); }); @@ -1995,8 +2105,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))), ); }); @@ -2020,6 +2145,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); const hardReload = (tabId: string) => withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); + const findInPage = ( + tabId: string, + text: string, + options: { readonly forward: boolean; readonly newSession: boolean }, + ) => + withWebContents("findInPage", tabId, (wc) => { + // Electron's `findNext` means "begin a new session", not "go to the next match". + wc.findInPage(text, { forward: options.forward, findNext: options.newSession }); + }); + const stopFindInPage = (tabId: string) => + withWebContents("stopFindInPage", tabId, (wc) => wc.stopFindInPage("clearSelection")).pipe( + Effect.catchTags({ + PreviewTabNotFoundError: () => Effect.void, + PreviewWebContentsNotFoundError: () => Effect.void, + }), + ); const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { const wc = yield* requireWebContents(tabId); @@ -2074,16 +2215,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); }); - const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) { + // Voice state reaches a guest only while its pick session, by id, still owns the tab. + const updateAnnotationVoice = Effect.fn("PreviewManager.updateAnnotationVoice")(function* ( + state: DesktopPreviewAnnotationVoiceState, + ) { + const sessions = yield* Ref.get(pickSessionsRef); + if (sessions.get(state.tabId)?.voiceSessionId !== state.sessionId) return; + const wc = yield* requireWebContents(state.tabId); + yield* attempt({ operation: "annotationVoice.update", tabId: state.tabId }, () => + wc.send(ANNOTATION_VOICE_STATE_CHANNEL, state), + ); + }); + + const pickElement = Effect.fn("PreviewManager.pickElement")(function* ( + tabId: string, + voice?: DesktopPreviewAnnotationVoiceConfig, + ) { const wc = yield* requireWebContents(tabId); yield* cancelPickElement(tabId); const annotationTheme = yield* Ref.get(annotationThemeRef); return yield* Effect.callback( (resume) => { - const session: PickSession = { cancel: Effect.suspend(() => cancelPickSession()) }; + const session: PickSession = { + voiceSessionId: voice?.sessionId, + cancel: Effect.suspend(() => cancelPickSession()), + }; const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); + wc.ipc.removeListener(ANNOTATION_VOICE_EVENT_CHANNEL, onVoiceEvent); wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); }).pipe(Effect.ignore); @@ -2191,6 +2351,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ); }; + // Only this guest's listener sees these; the renderer still checks the session id. + const onVoiceEvent = (_event: Electron.IpcMainEvent, value: unknown) => { + const decoded = decodeAnnotationVoiceEvent(value); + if (settled || !voice || Option.isNone(decoded)) return; + if (decoded.value.sessionId !== voice.sessionId) return; + runFork( + Effect.gen(function* () { + const window = yield* Ref.get(mainWindowRef); + if (Option.isNone(window) || window.value.isDestroyed()) return; + yield* attempt({ operation: "annotationVoice.forward", tabId }, () => + window.value.webContents.send( + PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, + tabId, + decoded.value, + ), + ).pipe(Effect.ignore); + }), + ); + }; const onDestroyed = () => settle(null); const onNavigated = ( event: Electron.Event, @@ -2210,10 +2389,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (settled) return; yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); + if (voice) wc.ipc.on(ANNOTATION_VOICE_EVENT_CHANNEL, onVoiceEvent); wc.once("destroyed", onDestroyed); wc.on("did-start-navigation", onNavigated); if (!wc.isFocused()) wc.focus(); - wc.send(START_PICK_CHANNEL, annotationTheme, annotationSendEnabled.get(tabId) === true); + wc.send( + START_PICK_CHANNEL, + annotationTheme, + annotationSendEnabled.get(tabId) === true, + voice, + ); }); }); runFork( @@ -2280,14 +2465,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (beforeAttach?.webContentsId !== wc.id) return; const control = yield* ensureControlSession(wc); + if (beforeAttach.serverTab) yield* listenToBrowserHost; const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (afterAttach?.webContentsId !== wc.id) { yield* detachControlSession(wc.id); return; } if (afterAttach.serverTab) { - yield* listenToBrowserHost; browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger }); + // CDP cannot recover a failure that happened before it attached. + const { navStatus } = afterAttach; + if (navStatus.kind === "LoadFailed") { + browserHost.loadFailed(afterAttach.serverTab, { + url: navStatus.url, + code: navStatus.code, + description: navStatus.description, + }); + } } if (afterAttach.colorScheme !== "system") { yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => @@ -3269,6 +3463,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { discard: true }, ); }); + const emitFindResult = Effect.fn("PreviewManager.emitFindResult")(function* ( + event: DesktopPreviewFindResultEvent, + ) { + const listeners = yield* Ref.get(findResultListenersRef); + yield* Effect.forEach( + listeners, + (listener) => deliverEvent("find-result", event.tabId, () => listener(event)), + { discard: true }, + ); + }); const subscribe = ( ref: Ref.Ref>, listener: A, @@ -3291,6 +3495,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(openLinkListenersRef, new Set()), + Ref.set(findResultListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, @@ -3305,6 +3510,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function closeTab, copyArtifactToClipboard, createTab, + findInPage, goBack, goForward, hardReload, @@ -3312,6 +3518,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + updateAnnotationVoice, prepareWebview, reapplyZoom, refresh, @@ -3333,10 +3540,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), startRecording, closePictureInPicture, + stopFindInPage, stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), + subscribeFindResults: (listener: FindResultListener) => + subscribe(findResultListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), @@ -3518,6 +3728,12 @@ export class PreviewManager extends Context.Service< readonly resetZoom: (tabId: string) => Effect.Effect; readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; + readonly findInPage: ( + tabId: string, + text: string, + options: { readonly forward: boolean; readonly newSession: boolean }, + ) => Effect.Effect; + readonly stopFindInPage: (tabId: string) => Effect.Effect; readonly setColorScheme: ( tabId: string, colorScheme: DesktopPreviewColorScheme, @@ -3552,7 +3768,11 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; readonly pickElement: ( tabId: string, + voice?: DesktopPreviewAnnotationVoiceConfig, ) => Effect.Effect; + readonly updateAnnotationVoice: ( + state: DesktopPreviewAnnotationVoiceState, + ) => Effect.Effect; readonly cancelPickElement: (tabId: string) => Effect.Effect; readonly captureScreenshot: ( tabId: string, @@ -3578,6 +3798,9 @@ export class PreviewManager extends Context.Service< readonly subscribeOpenLinks: ( listener: OpenLinkListener, ) => Effect.Effect; + readonly subscribeFindResults: ( + listener: FindResultListener, + ) => Effect.Effect; readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; @@ -3607,7 +3830,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; } @@ -3668,6 +3902,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { resetZoom: operations.resetZoom, reapplyZoom: operations.reapplyZoom, hardReload: operations.hardReload, + findInPage: operations.findInPage, + stopFindInPage: operations.stopFindInPage, setColorScheme: operations.setColorScheme, setZoomFactor: operations.setZoomFactor, setAudioMuted: operations.setAudioMuted, @@ -3703,6 +3939,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { setAnnotationTheme: operations.setAnnotationTheme, setAnnotationSendEnabled: operations.setAnnotationSendEnabled, pickElement: operations.pickElement, + updateAnnotationVoice: operations.updateAnnotationVoice, cancelPickElement: operations.cancelPickElement, captureScreenshot: operations.captureScreenshot, revealArtifact: operations.revealArtifact, @@ -3715,6 +3952,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeOpenLinks: operations.subscribeOpenLinks, + subscribeFindResults: operations.subscribeFindResults, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/preview/PickPreload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts new file mode 100644 index 0000000000000..8a080183b3830 --- /dev/null +++ b/apps/desktop/src/preview/PickPreload.test.ts @@ -0,0 +1,64 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { START_PICK_CHANNEL } from "./GuestProtocol.ts"; +import "./PickPreload.ts"; + +const ipcListeners = vi.hoisted(() => new Map void>>()); + +vi.mock("electron", () => ({ + ipcRenderer: { + on: (channel: string, listener: (...args: unknown[]) => void) => { + ipcListeners.set(channel, [...(ipcListeners.get(channel) ?? []), listener]); + }, + off: () => {}, + send: () => {}, + invoke: async () => undefined, + }, +})); + +vi.mock("react-grab/primitives", () => ({ getElementContext: async () => null })); + +const startAnnotation = (): ShadowRoot => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + using attachShadow = vi.spyOn(Element.prototype, "attachShadow"); + for (const listener of ipcListeners.get(START_PICK_CHANNEL) ?? []) listener({}, undefined, false); + const shadowRoot = attachShadow.mock.results[0]?.value; + if (!(shadowRoot instanceof ShadowRoot)) throw new Error("annotation overlay did not start"); + return shadowRoot; +}; + +it("switches tools from the keyboard after a toolbar button is clicked", () => { + const shadowRoot = startAnnotation(); + const region = Array.from(shadowRoot.querySelectorAll("button")).find( + (button) => button.textContent === "Region", + ); + if (!region) throw new Error("Region toolbar button not found"); + + region.focus(); + region.click(); + expect(document.documentElement.getAttribute("data-t3code-annotation-tool")).toBe("marquee"); + + region.dispatchEvent(new KeyboardEvent("keydown", { key: "v", bubbles: true, composed: true })); + + expect( + document.documentElement.getAttribute("data-t3code-annotation-tool"), + "V should switch to Select while focus is on a toolbar button", + ).toBe("select"); +}); + +it("keeps the tool while typing a tool key in the comment", () => { + const shadowRoot = startAnnotation(); + const comment = shadowRoot.querySelector("textarea"); + if (!comment) throw new Error("comment textarea not found"); + + comment.focus(); + comment.dispatchEvent(new KeyboardEvent("keydown", { key: "r", bubbles: true, composed: true })); + + expect(document.documentElement.getAttribute("data-t3code-annotation-tool")).toBe("select"); +}); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 4cb5987cdeb85..a9f479d32b81a 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1,8 +1,12 @@ // @effect-diagnostics globalDate:off globalTimers:off - This isolated Electron preload does not run inside an Effect runtime. import { ipcRenderer } from "electron"; import { getElementContext } from "react-grab/primitives"; +import { matchesKeybindingShortcut } from "@t3tools/shared/keybindingMatching"; import type { DesktopPreviewAnnotationTheme, + DesktopPreviewAnnotationVoiceConfig, + DesktopPreviewAnnotationVoiceEvent, + DesktopPreviewAnnotationVoiceState, PickedElementPayload, PickedElementStackFrame, PreviewAnnotationPayload, @@ -23,10 +27,13 @@ import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_SEND_ENABLED_CHANNEL, ANNOTATION_THEME_CHANNEL, + ANNOTATION_VOICE_EVENT_CHANNEL, + ANNOTATION_VOICE_STATE_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_POINTER_CHANNEL, RECORDING_KEY_CHANNEL, @@ -43,6 +50,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( @@ -188,6 +205,26 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); +// Capture runs before a page can stop the event; the task delay lets every page +// listener call preventDefault before the host is told the key went unhandled. +const reportUnhandledShortcut = (event: KeyboardEvent): void => { + if (!event.isTrusted || event.repeat || event.isComposing) return; + if (!event.metaKey && !event.ctrlKey && !event.altKey && !/^F\d{1,2}$/u.test(event.key)) return; + setTimeout(() => { + if (event.defaultPrevented) return; + ipcRenderer.send(PAGE_UNHANDLED_SHORTCUT_CHANNEL, { + key: event.key, + code: event.code, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + }); + }, 0); +}; + +window.addEventListener("keydown", reportUnhandledShortcut, true); + const MOUSE_BUTTON_BACK = 3; const MOUSE_BUTTON_FORWARD = 4; @@ -524,7 +561,10 @@ function strokeBounds( return { x: left, y: top, width: right - left, height: bottom - top }; } -function startAnnotation(sendEnabled: boolean): void { +function startAnnotation( + sendEnabled: boolean, + voice: DesktopPreviewAnnotationVoiceConfig | undefined, +): void { activeSession?.teardown(false); let finished = false; const host = document.createElement("div"); @@ -595,6 +635,225 @@ function startAnnotation(sendEnabled: boolean): void { "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; composerRow.appendChild(comment); + // Recording happens in the application renderer, which owns the microphone consent. + // This guest only draws the controls, forwards trusted gestures, and mirrors state. + let voicePhase: DesktopPreviewAnnotationVoiceState["phase"] = "idle"; + let voiceSettings = false; + let voiceBusy = false; + let voiceAvailable = false; + let voiceKeyCode: string | null = null; + let pendingVoiceSubmission: PreviewAnnotationSubmission | null = null; + let voiceSubmissionReady = false; + const voiceButton = createButton("", "Start voice input"); + voiceButton.innerHTML = + ''; + voiceButton.className += " h-8 w-8 shrink-0 p-0 text-muted-foreground"; + voiceButton.hidden = true; + voiceButton.setAttribute("aria-label", "Start voice input"); + composerRow.appendChild(voiceButton); + const voiceRow = document.createElement("div"); + voiceRow.className = "flex min-w-0 items-center justify-end gap-1 px-2 pb-2"; + voiceRow.hidden = true; + const voicePill = document.createElement("div"); + const voiceCancel = createButton("", "Cancel voice input"); + voiceCancel.innerHTML = + ''; + voiceCancel.className += " h-7 w-7 shrink-0 rounded-full p-0"; + const voiceFinish = createButton("", "Finish voice input"); + voiceFinish.className += + " h-7 w-7 shrink-0 rounded-full border-primary bg-primary p-0 text-primary-foreground hover:bg-primary/90"; + const voiceSkip = createButton("Skip", "Skip transcript cleanup"); + voiceSkip.className += " h-7 shrink-0 rounded-full px-2.5"; + // createButton's rounded-md sorts after rounded-full in the generated CSS, so it would win. + for (const button of [voiceCancel, voiceFinish, voiceSkip]) button.classList.remove("rounded-md"); + const voiceStatus = document.createElement("div"); + voiceStatus.className = "flex h-7 min-w-0 flex-1 items-center gap-2"; + voiceStatus.setAttribute("role", "status"); + const voiceStatusText = document.createElement("span"); + voiceStatusText.className = "min-w-0 flex-1 truncate text-right text-sm text-muted-foreground"; + const waveform = document.createElement("div"); + waveform.className = + "flex h-5 min-w-0 flex-1 items-center justify-between gap-px overflow-hidden"; + waveform.setAttribute("aria-hidden", "true"); + const waveformLevels = Array(24).fill(0); + const waveformBars = waveformLevels.map(() => { + const bar = document.createElement("span"); + bar.className = "h-full w-0.5 shrink-0 origin-center rounded-full bg-primary"; + bar.style.transform = "scaleY(0.08)"; + bar.style.opacity = "0.25"; + waveform.appendChild(bar); + return bar; + }); + const voiceElapsed = document.createElement("span"); + voiceElapsed.className = "shrink-0 text-xs tabular-nums text-muted-foreground"; + let recordingStartedAt = 0; + let elapsedTimer: ReturnType | null = null; + const updateElapsed = () => { + const seconds = Math.floor((Date.now() - recordingStartedAt) / 1_000); + voiceElapsed.textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; + }; + voiceStatus.append(voiceStatusText, waveform, voiceElapsed); + voicePill.append(voiceCancel, voiceStatus, voiceSkip, voiceFinish); + voiceRow.appendChild(voicePill); + const voicePreview = document.createElement("p"); + voicePreview.className = "px-2 pb-2 text-sm text-muted-foreground"; + voicePreview.setAttribute("role", "status"); + voicePreview.hidden = true; + const sendVoice = ( + action: DesktopPreviewAnnotationVoiceEvent["action"], + keyboard?: KeyboardEvent, + ) => { + if (!voice || finished) return; + const event: DesktopPreviewAnnotationVoiceEvent = { + sessionId: voice.sessionId, + text: comment.value, + cursor: comment.selectionStart, + selectionEnd: comment.selectionEnd, + action, + ...(keyboard + ? { + keyboard: { + type: keyboard.type === "keyup" ? ("keyup" as const) : ("keydown" as const), + key: keyboard.key, + code: keyboard.code, + metaKey: keyboard.metaKey, + ctrlKey: keyboard.ctrlKey, + shiftKey: keyboard.shiftKey, + altKey: keyboard.altKey, + repeat: keyboard.repeat, + }, + } + : {}), + }; + ipcRenderer.send(ANNOTATION_VOICE_EVENT_CHANNEL, event); + }; + const isDictationChord = (event: KeyboardEvent) => + voice !== undefined && + voice.shortcuts.some((shortcut) => + matchesKeybindingShortcut(event, shortcut, navigator.platform), + ); + for (const button of [voiceButton, voiceFinish, voiceCancel, voiceSkip]) { + button.addEventListener("pointerdown", (event) => event.preventDefault()); + } + // Page scripts cannot reach the closed shadow root, and a synthetic click never counts. + voiceButton.addEventListener("click", (event) => { + if (!event.isTrusted) return; + if (voiceSettings) { + sendVoice("settings"); + return; + } + if (!voiceAvailable || (voiceBusy && voicePhase !== "recording")) return; + sendVoice(voicePhase === "recording" ? "stop" : "start"); + }); + voiceFinish.addEventListener("click", (event) => { + if (event.isTrusted) sendVoice("stop"); + }); + voiceCancel.addEventListener("click", (event) => { + if (!event.isTrusted) return; + pendingVoiceSubmission = null; + sendVoice("cancel"); + }); + voiceSkip.addEventListener("click", (event) => { + if (event.isTrusted) sendVoice("skip"); + }); + const onVoiceState = ( + _event: Electron.IpcRendererEvent, + state: DesktopPreviewAnnotationVoiceState, + ) => { + if (finished || state.sessionId !== voice?.sessionId) return; + const phaseChanged = voicePhase !== state.phase; + const submissionChanged = voiceBusy !== state.blocksSubmission; + const previewChanged = voicePreview.textContent !== (state.preview ?? ""); + voicePhase = state.phase; + voiceSettings = state.errorAction === "settings"; + voiceBusy = state.blocksSubmission; + voiceAvailable = state.available; + comment.readOnly = state.freezesEditor; + if (state.draft) { + voiceSubmissionReady = pendingVoiceSubmission !== null; + comment.value = state.draft.text; + comment.setSelectionRange(state.draft.cursor, state.draft.cursor); + resizeComment(); + } + const recording = state.phase === "recording"; + const active = state.phase !== "idle" && state.phase !== "error"; + const wasActive = voicePill.parentElement === composerRow; + voiceButton.hidden = !state.available || active; + voiceButton.disabled = voiceBusy; + const label = voiceSettings ? "Open voice settings" : "Start voice input"; + voiceButton.title = + !voiceSettings && state.shortcutLabel ? `${label} (${state.shortcutLabel})` : label; + voiceButton.setAttribute("aria-label", label); + // An active dictation takes the textarea's place; it takes focus once (sr-only) so Enter still attaches. + voiceRow.hidden = state.phase === "idle" || active; + comment.classList.toggle("sr-only", active); + if (active && !wasActive) comment.focus({ preventScroll: true }); + submit.hidden = active; + // Re-inserting on every level update would blur a focused Finish or Cancel. + if (active && voicePill.parentElement !== composerRow) comment.after(voicePill); + else if (!active && voicePill.parentElement !== voiceRow) voiceRow.appendChild(voicePill); + voicePill.className = active + ? "flex h-8 min-w-0 flex-1 items-center gap-2" + : "flex min-w-0 items-center gap-1"; + // A draft that arrived while the textarea was sr-only was measured at 1px wide. + if (wasActive && !active) resizeComment(); + else if (phaseChanged) queueEditorLayout(); + voiceCancel.title = + state.phase === "error" ? "Dismiss voice input error" : "Cancel voice input"; + voiceCancel.setAttribute("aria-label", voiceCancel.title); + voiceStatus.setAttribute("aria-label", state.status ?? "Voice input"); + voiceStatus.setAttribute("aria-live", recording ? "off" : "polite"); + voiceStatusText.textContent = state.status; + voiceStatusText.style.color = state.phase === "error" ? "var(--t3-destructive)" : ""; + voiceStatusText.hidden = recording; + waveform.hidden = !recording; + voiceElapsed.hidden = !recording; + if (recording && elapsedTimer === null) { + recordingStartedAt = Date.now(); + waveformLevels.fill(0); + updateElapsed(); + elapsedTimer = setInterval(updateElapsed, 250); + } else if (!recording && elapsedTimer !== null) { + clearInterval(elapsedTimer); + elapsedTimer = null; + } + if (recording) { + waveformLevels.copyWithin(0, 1); + waveformLevels[waveformLevels.length - 1] = state.level; + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + waveformBars.forEach((bar, index) => { + const level = waveformLevels[index] ?? 0; + bar.style.opacity = String(0.25 + level * 0.75); + bar.style.transform = `scaleY(${reducedMotion ? 0.35 : Math.max(0.08, level)})`; + }); + } + voiceSkip.hidden = state.phase !== "post-processing"; + voiceFinish.hidden = !active || state.phase === "post-processing"; + voiceFinish.disabled = !recording; + voiceFinish.title = recording ? "Finish voice input" : (state.status ?? "Voice input is busy"); + if (recording && state.shortcutLabel) voiceFinish.title += ` (${state.shortcutLabel})`; + voiceFinish.setAttribute("aria-label", voiceFinish.title); + if (phaseChanged) + voiceFinish.innerHTML = recording + ? '' + : ''; + if (!active) voicePill.append(voiceStatus, voiceCancel); + else if (phaseChanged) voicePill.append(voiceCancel, voiceStatus, voiceSkip, voiceFinish); + voicePreview.textContent = state.preview; + voicePreview.hidden = !state.preview; + if (phaseChanged || submissionChanged || previewChanged || state.draft) updateStatus(); + if (pendingVoiceSubmission !== null) { + if (state.phase === "recording") sendVoice("stop"); + if (state.phase === "error") pendingVoiceSubmission = null; + if (state.phase === "idle") { + const submission = pendingVoiceSubmission; + pendingVoiceSubmission = null; + if (voiceSubmissionReady && submission !== null) submitAnnotation(submission); + voiceSubmissionReady = false; + } + } + }; + const dragHandle = document.createElement("button"); dragHandle.type = "button"; dragHandle.textContent = "⠿"; @@ -613,7 +872,7 @@ function startAnnotation(sendEnabled: boolean): void { submit.className += " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; composerRow.appendChild(submit); - editor.appendChild(composerRow); + editor.append(composerRow, voicePreview, voiceRow); const stylePanel = document.createElement("div"); stylePanel.className = @@ -654,12 +913,19 @@ function startAnnotation(sendEnabled: boolean): void { comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; queueEditorLayout(); }; - comment.addEventListener("input", resizeComment); + comment.addEventListener("input", () => { + resizeComment(); + sendVoice("sync"); + }); const updateStatus = (): void => { const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; + if (!hasTargets && voiceBusy) { + pendingVoiceSubmission = null; + sendVoice("cancel"); + } editor.style.display = hasTargets ? "flex" : "none"; - submit.disabled = !hasTargets; + submit.disabled = !hasTargets || voicePhase === "preparing" || pendingCapture; submit.style.opacity = hasTargets ? "1" : "0.45"; adjust.disabled = !hasTargets; stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; @@ -695,7 +961,6 @@ function startAnnotation(sendEnabled: boolean): void { for (const [key, change] of styleChanges) { if (change.targetId === target.id) styleChanges.delete(key); } - updateStatus(); }; const addSelected = (element: Element): void => { @@ -710,7 +975,6 @@ function startAnnotation(sendEnabled: boolean): void { selected.set(element, target); root.append(target.outline, target.label); updateSelectedVisual(target); - updateStatus(); if (editorExpanded) { stylePanel.style.display = "grid"; syncStyleControls(); @@ -721,12 +985,13 @@ function startAnnotation(sendEnabled: boolean): void { const existing = selected.get(element); if (existing) { removeSelected(existing); - return; - } - if (!additive) { - for (const target of Array.from(selected.values())) removeSelected(target); + } else { + if (!additive) { + for (const target of Array.from(selected.values())) removeSelected(target); + } + addSelected(element); } - addSelected(element); + updateStatus(); }; const setStyleForSelected = (property: string, value: string): void => { @@ -1140,6 +1405,7 @@ function startAnnotation(sendEnabled: boolean): void { const rect = target.element.getBoundingClientRect(); if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { removeSelected(target); + updateStatus(); return true; } } @@ -1314,11 +1580,22 @@ function startAnnotation(sendEnabled: boolean): void { 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(); }; + const onWindowFocus = (): void => { + if (editor.style.display !== "none") sendVoice("focus"); + }; const onWindowBlur = (): void => { + sendVoice("blur"); clearHoverOutline(); }; @@ -1344,16 +1621,22 @@ function startAnnotation(sendEnabled: boolean): void { window.removeEventListener("pointerout", onPointerOut, true); window.removeEventListener("click", onClick, true); window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("focus", onWindowFocus); window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("keyup", onVoiceKeyUp, true); window.removeEventListener("scroll", repaint, true); window.removeEventListener("resize", repaint); dragHandle.removeEventListener("pointerdown", onEditorPointerDown); 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); + ipcRenderer.off(ANNOTATION_VOICE_STATE_CHANNEL, onVoiceState); + if (elapsedTimer !== null) clearInterval(elapsedTimer); document.documentElement.removeAttribute("data-t3code-annotation-tool"); cursorStyle.remove(); inputLayer?.remove(); @@ -1364,14 +1647,39 @@ function startAnnotation(sendEnabled: boolean): void { const onCancel = (): void => teardown(false); const onCaptured = (): void => teardown(false); + const onVoiceKeyUp = (event: KeyboardEvent): void => { + if (!event.isTrusted || (event.code || event.key) !== voiceKeyCode) return; + voiceKeyCode = null; + event.preventDefault(); + event.stopImmediatePropagation(); + sendVoice("key", event); + }; const onKeyDown = (event: KeyboardEvent): void => { - if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; + // Only the keyboard drives dictation; a page cannot synthesize the chord or Escape. + if (voice && event.isTrusted && !event.isComposing && event.keyCode !== 229) { + if (event.key === "Escape" && voiceBusy) { + event.preventDefault(); + event.stopImmediatePropagation(); + voiceKeyCode = null; + pendingVoiceSubmission = null; + sendVoice("key", event); + return; + } + if (voiceAvailable && editor.style.display !== "none" && isDictationChord(event)) { + event.preventDefault(); + event.stopImmediatePropagation(); + voiceKeyCode = event.code || event.key; + sendVoice("key", event); + return; + } + } if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); teardown(true); return; } + if (shadowRoot.activeElement?.matches("input, textarea, select")) return; if (event.key === "v") tool = "select"; else if (event.key === "r") tool = "marquee"; else if (event.key === "d") tool = "draw"; @@ -1382,6 +1690,16 @@ function startAnnotation(sendEnabled: boolean): void { const submitAnnotation = (submission: PreviewAnnotationSubmission): void => { if (submission === "send" && !sendEnabled) return; + if (voicePhase === "preparing") return; + // Submitting while dictating finishes the recording and submits once the text lands. + if (voiceBusy) { + if (pendingVoiceSubmission === null) { + pendingVoiceSubmission = submission; + voiceSubmissionReady = false; + if (voicePhase === "recording") sendVoice("stop"); + } + return; + } if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) return; pendingCapture = true; @@ -1460,11 +1778,18 @@ function startAnnotation(sendEnabled: boolean): void { window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); window.addEventListener("click", onClick, { capture: true, passive: false }); window.addEventListener("blur", onWindowBlur); + window.addEventListener("focus", onWindowFocus); window.addEventListener("keydown", onKeyDown, { capture: true }); + 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); if (inputLayer) { document.documentElement.appendChild(inputLayer); inputLayer.focus({ preventScroll: true }); @@ -1473,6 +1798,7 @@ function startAnnotation(sendEnabled: boolean): void { rootResizeObserver.observe(document.documentElement); refreshToolButtons(); updateStatus(); + sendVoice(document.hasFocus() ? "focus" : "sync"); activeSession = { teardown, applyTheme: (theme) => applyAnnotationTheme(host, theme), @@ -1485,9 +1811,14 @@ function startAnnotation(sendEnabled: boolean): void { ipcRenderer.on( START_PICK_CHANNEL, - (_event, theme: DesktopPreviewAnnotationTheme | undefined, sendEnabled?: boolean) => { + ( + _event, + theme: DesktopPreviewAnnotationTheme | undefined, + sendEnabled?: boolean, + voice?: DesktopPreviewAnnotationVoiceConfig, + ) => { if (theme) annotationTheme = theme; - startAnnotation(sendEnabled === true); + startAnnotation(sendEnabled === true, voice); }, ); ipcRenderer.on(ANNOTATION_SEND_ENABLED_CHANNEL, (_event, enabled: boolean) => { 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/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 7e4b530083097..868e1c139fbdd 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -200,16 +200,22 @@ describe("DesktopSettings", () => { withSettings( Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, - mainWindowMaximized: true, - serverExposureMode: "network-accessible", - }); - - const loaded = yield* settings.load; - assert.isNull(loaded.mainWindowBounds); - assert.isFalse(loaded.mainWindowMaximized); - assert.equal(loaded.serverExposureMode, "network-accessible"); + for (const bounds of [ + { x: 10.5, y: 20, width: 400, height: 360 }, + { x: 10, y: 20, width: 399, height: 360 }, + { x: 10, y: 20, width: 400, height: 359 }, + ]) { + yield* writeSettingsPatch({ + mainWindowBounds: bounds, + mainWindowMaximized: true, + serverExposureMode: "network-accessible", + }); + + const loaded = yield* settings.load; + assert.isNull(loaded.mainWindowBounds); + assert.isFalse(loaded.mainWindowMaximized); + assert.equal(loaded.serverExposureMode, "network-accessible"); + } }), ), ); @@ -221,17 +227,24 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 400, height: 360 }, true); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { - mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, + mainWindowBounds: { x: -1200, y: 40, width: 400, height: 360 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); + + const restored = yield* Effect.gen(function* () { + const freshSettings = yield* DesktopAppSettings.DesktopAppSettings; + return yield* freshSettings.load; + }).pipe(Effect.provide(DesktopAppSettings.layer)); + assert.deepEqual(restored.mainWindowBounds, persisted.mainWindowBounds); + assert.isTrue(restored.mainWindowMaximized); }), ), ); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 6cd34322d98c2..4c2ae8d1d1342 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -47,9 +47,12 @@ export interface DesktopSettingsChange { } const DEFAULT_TAILSCALE_SERVE_PORT = 443; -const MIN_MAIN_WINDOW_SIZE = { - width: 840, - height: 620, +// Fits half and third tiles of common scaled displays (down to 480x432 DIP) on +// Wayland, where Electron's client-side frame adds 32x42 DIP to the enforced +// minimum. The web layout already handles phone widths. +export const MIN_MAIN_WINDOW_SIZE = { + width: 400, + height: 360, } as const; export const DesktopWindowBoundsSchema = Schema.Struct({ x: Schema.Int, diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index c1c8ae16499d5..1572823e201e9 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -37,7 +37,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, confirmThreadUnpin: false, copyOnSelect: false, - contextWindowMeterEnabled: false, diffFilesCollapsed: true, diffIgnoreWhitespace: true, diffLayout: "stacked", @@ -55,7 +54,6 @@ const clientSettings: ClientSettings = { glassOpacity: 80, onboardingCompletedAt: null, panelAnimationDurationMs: 0, - planModeEnabled: false, proactivePanelsEnabled: true, showSkillsInSlashMenu: false, persistComposerContextStrip: true, @@ -70,7 +68,6 @@ const clientSettings: ClientSettings = { boringSidebarMultilineRows: false, threadDetailsUsageEnabled: false, threadDetailsItems: {}, - legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, 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 335c66affda54..b9cbeb0cd7965 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" @@ -1047,7 +1048,6 @@ it.effect.each([ { backend: "gnome", screenshot: "PNG" }, { backend: "niri", screenshot: "PNG" }, { backend: "kde", screenshot: "PNG" }, - { backend: "hyprland", screenshot: "PNG" }, { backend: "gnome", screenshot: "invalid" }, ] as const)( "persists $backend accessibility text with a $screenshot screenshot", @@ -1402,7 +1402,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 +1442,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 +1484,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 8144ef82bc922..4585a6bc1dacc 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/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index c0f8210f18146..1e5b6dafc6cae 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -8,7 +8,10 @@ import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import { + SSH_PASSWORD_PROMPT_CHANNEL, + SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, +} from "../ipc/channels.ts"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; interface SentMessage { @@ -144,6 +147,52 @@ describe("DesktopSshPasswordPrompts", () => { }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); }); + it.effect.each(["answered", "cancelled", "timed out", "interrupted"] as const)( + "tells the window a prompt ended when it is %s", + (outcome) => { + const testWindow = makeTestWindow(); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const fiber = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.exit, Effect.forkScoped); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + const request = testWindow.sentMessages[0]?.args[0] as { readonly requestId: string }; + assert.ok(request); + switch (outcome) { + case "answered": + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); + break; + case "cancelled": + yield* prompts.resolve({ requestId: request.requestId, password: null }); + break; + case "timed out": + yield* TestClock.adjust("1 second"); + break; + case "interrupted": + yield* Fiber.interrupt(fiber); + break; + } + yield* Fiber.await(fiber); + + assert.deepEqual( + testWindow.sentMessages + .filter((message) => message.channel === SSH_PASSWORD_PROMPT_SETTLED_CHANNEL) + .map((message) => message.args), + [[request.requestId]], + ); + }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }, + ); + it.effect("keeps a submitted password when a later presentation step fails", () => { const testWindow = makeTestWindow({ isMinimizedError: new Error("failed to read minimized state"), diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 31602918ea6f6..5391eb2b4547b 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -13,7 +13,10 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import { + SSH_PASSWORD_PROMPT_CHANNEL, + SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, +} from "../ipc/channels.ts"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; @@ -38,6 +41,7 @@ const DesktopSshPromptPresentationOperation = Schema.Literals([ "check-window-after-restore", "focus-window", "remove-window-close-listener", + "send-prompt-settled", ]); type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type; @@ -371,11 +375,22 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( cause, }), }); + // However the request ends, the window drops its prompt, so no ended prompt stays on screen. + const notifySettled = runPresentationOperation("send-prompt-settled", () => { + if (!window.value.isDestroyed()) { + window.value.webContents.send(SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, requestId); + } + }).pipe(Effect.ignore); const cleanup = runPresentationOperation("remove-window-close-listener", () => { if (!window.value.isDestroyed()) { window.value.removeListener("closed", cancelOnWindowClosed); } - }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid); + }).pipe( + Effect.orDie, + Effect.ensuring(removePending(pendingRef, requestId)), + Effect.ensuring(notifySettled), + Effect.asVoid, + ); const waitForPassword = Deferred.await(deferred).pipe( Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), Effect.flatMap( diff --git a/apps/desktop/src/telemetry/DesktopRendererHistory.ts b/apps/desktop/src/telemetry/DesktopRendererHistory.ts index ed1590f63b19b..dc3e157f95351 100644 --- a/apps/desktop/src/telemetry/DesktopRendererHistory.ts +++ b/apps/desktop/src/telemetry/DesktopRendererHistory.ts @@ -273,7 +273,11 @@ const make = Effect.fn("DesktopRendererHistory.make")(function* () { accepting = false; for (const renderer of active.values()) renderer.removeListeners(); active.clear(); - }).pipe(Effect.andThen(Fiber.interrupt(writerFiber)), Effect.asVoid); + }).pipe( + Effect.andThen(Fiber.interrupt(writerFiber)), + Effect.asVoid, + Effect.withSpan("desktop.rendererHistory.shutdown"), + ); yield* Effect.addFinalizer(() => shutdown); return DesktopRendererHistory.of({ register, recordMetrics, shutdown }); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index a1ec164fcdf5e..04d13c580e99f 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -37,6 +37,7 @@ function layerElectronApp( metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, preventAppSuspension: Effect.void, requestSingleInstanceLock: Effect.succeed(true), @@ -92,6 +93,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/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..604fbeda3c9f1 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,5 +1,8 @@ +import * as NodeModule from "node:module"; + import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; +import { computeReleaseNotes } from "electron-updater/out/providers/GitHubProvider.js"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -22,6 +25,57 @@ import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; +type ReleaseFeed = Parameters[2]; +type RunningVersion = Parameters[0]; + +// electron-updater's own dependencies, which apps/desktop does not declare. +const requireFromUpdater = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("electron-updater"), +); +const { parseXml } = requireFromUpdater("builder-util-runtime") as { + parseXml: (xml: string) => ReleaseFeed; +}; +const semver = requireFromUpdater("semver") as { parse: (version: string) => RunningVersion }; + +const releaseTitle = (version: string) => + version.includes("-nightly.") + ? `T3 Code Nightly ${version} (0123456789ab)` + : `T3 Code v${version}`; + +const releaseFeed = (...versions: ReadonlyArray) => + parseXml( + `${versions + .map( + (version) => + `${releaseTitle(version)}<ul><li>${version} change</li></ul>`, + ) + .join("")}`, + ); + +/** The update info electron-updater's GitHub provider reports when /releases/latest names `version`. */ +const stableUpdateInfo = ( + feed: ReleaseFeed, + version: string, + runningVersion: string, + fullChangelog: boolean, +) => { + const latestRelease = + feed + .getElements("entry") + .find((entry) => entry.element("link").attribute("href").endsWith(`/tag/v${version}`)) ?? + feed.element("entry"); + return { + version, + releaseName: latestRelease.elementValueOrEmpty("title"), + releaseNotes: computeReleaseNotes( + semver.parse(runningVersion), + fullChangelog, + feed, + latestRelease, + ), + }; +}; + describe("DesktopUpdates", () => { it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => Effect.gen(function* () { @@ -109,6 +163,51 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect.each([ + { + name: "shows no notes for a Stable release missing from the releases feed", + runningVersion: "1.2.3", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.5-nightly.20261101.1"], + expected: [], + }, + { + name: "shows a Stable release's own notes from the releases feed", + runningVersion: "1.2.3", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.4", "1.2.4-nightly.20261030.1"], + expected: [{ version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }], + }, + { + name: "shows a Stable release's notes when switching down from a newer Nightly", + runningVersion: "1.2.5-nightly.20261027.1", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.4"], + expected: [{ version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }], + }, + ])("$name", ({ runningVersion, feedVersions, expected }) => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit( + "update-available", + stableUpdateInfo( + releaseFeed(...feedVersions), + "1.2.4", + runningVersion, + harness.fullChangelog(), + ), + ); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.availableVersion, "1.2.4"); + assert.deepEqual(state.releaseNotes, expected); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("preserves a queued installer when the feed has no update", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 6279e8b6141a8..36492ca7b9063 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -25,6 +25,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -35,7 +36,10 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; -import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; +import { + normalizeDesktopUpdateReleaseNotes, + releaseNotesOfOfferedVersion, +} from "./releaseNotes.ts"; import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { @@ -71,6 +75,7 @@ const UpdateInfo = Schema.Struct({ version: Schema.String, // Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately. releaseNotes: Schema.optional(Schema.Unknown), + releaseName: Schema.optional(Schema.NullOr(Schema.String)), releaseDate: Schema.optional(Schema.String), }); @@ -819,7 +824,8 @@ export const make = Effect.gen(function* () { yield* Ref.set(installRunningBackendsRef, runningInstances); yield* Effect.forEach( instances, - (instance) => instance.stop({ timeout: Duration.seconds(5) }), + (instance) => + instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }), { concurrency: "unbounded" }, ); yield* electronUpdater.quitAndInstall({ @@ -937,7 +943,7 @@ export const make = Effect.gen(function* () { const checkedAt = yield* currentIsoTimestamp; const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( - info.releaseNotes, + releaseNotesOfOfferedVersion(info), info.version, state.channel, info.releaseDate, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index a319898222092..715c1767e6a04 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -171,6 +171,22 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** + * Untagged notes come from the feed entry named by releaseName, which is the newest entry when the + * offered release is not on the feed's first page. Keep them only when that entry is the offered one. + */ +export function releaseNotesOfOfferedVersion(info: { + readonly version: string; + readonly releaseNotes?: unknown; + readonly releaseName?: string | null | undefined; +}): unknown { + if (typeof info.releaseNotes !== "string" || !info.releaseName) return info.releaseNotes; + const namesVersion = new RegExp(`(?:^|[^\\w.-])v?${escapeRegExp(info.version)}(?![\\w.-])`); + return namesVersion.test(info.releaseName) ? info.releaseNotes : undefined; +} + /** fullChangelog includes all greater semvers, so preview cuts sort above nightlies of the same base version. */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 90b0252f7b95a..c1e763669d0c5 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"; @@ -31,6 +36,7 @@ const layerElectronApp = Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("PseudoCode"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, preventAppSuspension: Effect.void, requestSingleInstanceLock: Effect.succeed(true), @@ -227,10 +233,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 2c0b64050dac8..cf176c7150938 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..91d034cbd772b 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, @@ -231,7 +233,7 @@ function layerTest(input: { readonly destroyAll?: Effect.Effect; readonly onWindowCreate?: Effect.Effect; readonly openedExternalUrls?: unknown[]; - readonly copiedTexts?: string[]; + readonly onCopyText?: (text: string) => Effect.Effect; readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect; readonly previewZoomReapplies?: number[]; readonly onReveal?: (window: Electron.BrowserWindow) => void; @@ -323,10 +325,7 @@ function layerTest(input: { return true; }), openSystemSettings: () => Effect.succeed(true), - copyText: (text) => - Effect.sync(() => { - input.copiedTexts?.push(text); - }), + copyText: input.onCopyText ?? (() => Effect.void), } satisfies ElectronShell.ElectronShell["Service"]), layerElectronTheme, layerElectronWindow, @@ -479,6 +478,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 +645,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 +686,62 @@ 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)); + }), + ); + + it.effect("ignores input that arrives after the window is destroyed", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const beforeInput = fakeWindow.webContentsListeners.get("before-input-event"); + const inputEvent = fakeWindow.webContentsListeners.get("input-event"); + if (!beforeInput || !inputEvent) { + return yield* Effect.die("input listeners were not registered"); + } + + // Electron throws on any member access of a destroyed BrowserWindow. + fakeWindow.isDestroyed.mockReturnValue(true); + Object.defineProperty(fakeWindow.window, "webContents", { + get: () => { + throw new TypeError("Object has been destroyed"); + }, + }); + + // A held Cmd+Q quits from the grace timer, so the trailing Q keyUp lands here. + assert.doesNotThrow(() => + beforeInput( + { preventDefault: () => {} }, + { + type: "keyUp", + isAutoRepeat: false, + key: "q", + meta: true, + control: false, + alt: false, + shift: false, + }, + ), + ); + assert.doesNotThrow(() => inputEvent({}, { type: "gestureScrollEnd" })); }).pipe(Effect.provide(layer)); }), ); @@ -1153,6 +1232,56 @@ describe("DesktopWindow", () => { }), ); + it.effect("offers to copy the bare address when right-clicking a mailto link", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const popup = yield* Deferred.make(); + const copied = yield* Deferred.make(); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + onPopupTemplate: (input) => Deferred.succeed(popup, input), + onCopyText: (text) => Deferred.succeed(copied, text), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const contextMenu = fakeWindow.webContentsListeners.get("context-menu"); + if (!contextMenu) { + return yield* Effect.die("context-menu listener was not registered"); + } + contextMenu( + { preventDefault: vi.fn() }, + { + linkURL: "mailto:restaurant%40example.com?subject=Hi", + misspelledWord: "", + dictionarySuggestions: [], + mediaType: "none", + editFlags: { canCut: false, canCopy: false, canPaste: false, canSelectAll: true }, + }, + ); + + const { template } = yield* Deferred.await(popup); + const copyEmail = template.find((item) => item.label === "Copy Email Address"); + assert.isDefined( + copyEmail, + `no "Copy Email Address" item in ${JSON.stringify(template.map((item) => item.label ?? item.role ?? item.type))}`, + ); + copyEmail?.click?.( + {} as Electron.MenuItem, + fakeWindow.window, + {} as Electron.KeyboardEvent, + ); + assert.equal(yield* Deferred.await(copied), "restaurant@example.com"); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect( "retries opening the real main on activate when a failed post-readiness open left only the splash", () => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b2f643e55fb97..8c9e507ca4b90 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -53,6 +53,21 @@ class ContextMenuImageCopyError extends Schema.TaggedError { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return Option.none(); + } + if (url.protocol !== "mailto:" || url.pathname === "") return Option.none(); + try { + return Option.some(decodeURIComponent(url.pathname)); + } catch { + return Option.some(url.pathname); + } +} + function syncMacosWindowButtons(window: Electron.BrowserWindow): void { if (window.isDestroyed() || window.isFullScreen()) return; window.setWindowButtonPosition({ @@ -148,15 +163,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 ); } @@ -399,8 +418,8 @@ export const make = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return Option.none(); const window = yield* electronWindow.create({ ...initialBounds, - minWidth: 840, - minHeight: 620, + minWidth: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.width, + minHeight: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.height, show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), @@ -417,6 +436,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, }, }); @@ -585,6 +605,19 @@ export const make = Effect.gen(function* () { ); } + const mailtoAddress = parseMailtoAddress(params.linkURL); + if (Option.isSome(mailtoAddress)) { + menuTemplate.push( + { + label: "Copy Email Address", + click: () => { + void runPromise(electronShell.copyText(mailtoAddress.value)); + }, + }, + { type: "separator" }, + ); + } + if (params.mediaType === "image") { menuTemplate.push({ label: "Copy Image", @@ -672,6 +705,14 @@ export const make = Effect.gen(function* () { }); window.webContents.on("before-input-event", (event, input) => { quitShortcutHandler(event, input); + if (window.isDestroyed()) return; + // 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; @@ -680,6 +721,7 @@ export const make = Effect.gen(function* () { } }); window.webContents.on("input-event", (_event, input) => { + if (window.isDestroyed()) return; if (input.type === "gestureScrollBegin") { window.webContents.send(TRACKPAD_SCROLL_BEGIN_CHANNEL); } else if (input.type === "gestureScrollEnd") { @@ -692,7 +734,10 @@ export const make = Effect.gen(function* () { runFork(zoomWindow(window, direction)); }); - window.on("page-title-updated", (event) => { + // The renderer titles the window after the open thread; only a blank title + // falls back to the app name. + window.on("page-title-updated", (event, title) => { + if (title.trim() !== "") return; event.preventDefault(); window.setTitle(environment.displayName); }); @@ -775,7 +820,6 @@ export const make = Effect.gen(function* () { } clearDevelopmentLoadRetry(); developmentLoadRetryIndex = 0; - window.setTitle(environment.displayName); if (environment.platform === "darwin") syncMacosWindowButtons(window); }); window.webContents.on( @@ -865,6 +909,8 @@ export const make = Effect.gen(function* () { void runPromise(revealWhenReady); }); + // Desktop IPC only answers the main window, so register it before its renderer can call in. + yield* electronWindow.setMain(window); loadApplication(); if (environment.isDevelopment) { window.webContents.openDevTools({ mode: "detach" }); @@ -883,7 +929,6 @@ export const make = Effect.gen(function* () { const createMain = Effect.gen(function* () { const window = yield* createWindow(); if (Option.isSome(window)) { - yield* electronWindow.setMain(window.value); mainClosedDuringStartup = false; yield* logWindowInfo("main window created"); } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 0d0a74d510bc3..4bb1e4541dd94 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -5,6 +5,7 @@ import { isDesktopRuntimeExternalDependency } from "../../scripts/lib/desktop-ex import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; const repoEnv = loadRepoEnv(); +const domTests = ["src/preview/PickPreload.test.ts"]; // Install only bundle externals; the server bundle already carries its dependency tree. const isMainProcessExternal = (id: string) => @@ -25,6 +26,12 @@ export default defineConfig({ dependsOn: ["t3#build"], cache: false, }, + // CI bundle jobs skip the Linux browser secret helper; packaging stages one per arch. + "build:bundle": { + command: "node scripts/build-preview-annotation-css.mjs && vp pack", + dependsOn: ["t3#build"], + cache: false, + }, dev: { command: "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", @@ -120,7 +127,10 @@ export default defineConfig({ outExtensions: () => ({ js: ".cjs" }), entry: ["src/preview-pick-preload.ts"], deps: { - alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"), + alwaysBundle: (id) => + id === "react-grab" || + id.startsWith("react-grab/") || + id === "@t3tools/shared/keybindingMatching", }, }, { @@ -142,13 +152,32 @@ export default defineConfig({ }, ], test: { - include: [...defaultInclude, "../../native/kde-snap-shot/src/*.test.ts"], // The Windows lane runs workspace suites concurrently; filesystem-heavy // desktop integration tests can exceed Vitest's 5 second default there. testTimeout: 15_000, - setupFiles: [ - "../../packages/shared/src/testing/longTempDir.ts", - "../../packages/shared/src/testing/providerEnv.ts", + projects: [ + { + extends: true, + test: { + name: "unit", + include: [...defaultInclude, "../../native/kde-snap-shot/src/*.test.ts"], + exclude: ["**/node_modules/**", ...domTests], + setupFiles: [ + "../../packages/shared/src/testing/longTempDir.ts", + "../../packages/shared/src/testing/providerEnv.ts", + ], + }, + }, + { + extends: true, + test: { + name: "dom", + include: domTests, + // Guest preloads run in the page; these tests drive them against a DOM. + environment: "jsdom", + setupFiles: ["../../packages/shared/src/testing/providerEnv.ts"], + }, + }, ], }, }); diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 15a8a8f916333..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; @@ -8,6 +10,16 @@ delete process.env.VP_RUN_NODE_CLIENT_PATH; 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/public/robots.txt b/apps/marketing/public/robots.txt new file mode 100644 index 0000000000000..495a72e25e4b3 --- /dev/null +++ b/apps/marketing/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://t3.codes/sitemap.xml 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 d1aea12f09bc9..bd5d3e00a2f81 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -9,18 +9,52 @@ import { IOS_APP_STORE_URL, 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; description?: string; pageClass?: string; + /** 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. const socialImage = await getImage({ @@ -31,17 +65,16 @@ 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); --- - + - + {noindex ? : } @@ -61,13 +94,17 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); + + {alternates.map((entry) => )} + {title} + @@ -420,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; @@ -485,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)); } @@ -509,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/lib/seo.ts b/apps/marketing/src/lib/seo.ts new file mode 100644 index 0000000000000..452d53338689c --- /dev/null +++ b/apps/marketing/src/lib/seo.ts @@ -0,0 +1,79 @@ +import { ANDROID_PLAY_STORE_URL, GITHUB_REPOSITORY_URL, IOS_APP_STORE_URL } from "./site"; + +export const SITE_ORIGIN = "https://t3.codes"; +export const SITE_NAME = "T3 Code"; +export const SITE_TAGLINE = "The open-source control plane for coding agents"; + +// The canonical set of official properties. Search engines and AI answer engines read `sameAs` +// to tie an entity to its real homes, which is the signal a look-alike domain cannot copy. +export const OFFICIAL_PROFILES = [ + GITHUB_REPOSITORY_URL, + IOS_APP_STORE_URL, + ANDROID_PLAY_STORE_URL, + "https://discord.gg/jn4EGJjrvv", +] as const; + +export type JsonLd = Record; + +const ORGANIZATION_ID = `${SITE_ORIGIN}/#organization`; +const WEBSITE_ID = `${SITE_ORIGIN}/#website`; +const SOFTWARE_ID = `${SITE_ORIGIN}/#software`; + +export const absoluteUrl = (pathname: string): string => new URL(pathname, SITE_ORIGIN).href; + +export const organizationJsonLd = (): JsonLd => ({ + "@type": "Organization", + "@id": ORGANIZATION_ID, + name: "T3 Tools Inc", + url: SITE_ORIGIN, + logo: absoluteUrl("/apple-touch-icon.png"), + sameAs: ["https://github.com/pingdotgg"], +}); + +export const websiteJsonLd = (): JsonLd => ({ + "@type": "WebSite", + "@id": WEBSITE_ID, + name: SITE_NAME, + url: SITE_ORIGIN, + publisher: { "@id": ORGANIZATION_ID }, +}); + +export const softwareJsonLd = (): JsonLd => ({ + "@type": "SoftwareApplication", + "@id": SOFTWARE_ID, + name: SITE_NAME, + description: `${SITE_NAME}. ${SITE_TAGLINE}.`, + url: SITE_ORIGIN, + downloadUrl: absoluteUrl("/download"), + applicationCategory: "DeveloperApplication", + operatingSystem: "macOS, Windows, Linux, iOS, Android", + license: "https://opensource.org/license/mit", + isAccessibleForFree: true, + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + publisher: { "@id": ORGANIZATION_ID }, + sameAs: [...OFFICIAL_PROFILES], +}); + +export const breadcrumbJsonLd = (items: ReadonlyArray<{ name: string; path: string }>): JsonLd => ({ + "@type": "BreadcrumbList", + itemListElement: items.map((item, index) => ({ + "@type": "ListItem", + position: index + 1, + name: item.name, + item: absoluteUrl(item.path), + })), +}); + +export const jsonLdGraph = (nodes: ReadonlyArray): string => + // `<` is escaped so page text inside the graph can never close the script tag. + JSON.stringify({ "@context": "https://schema.org", "@graph": nodes }).replace(/ { + const body = [ + "Contact: mailto:security@t3.codes", + `Policy: ${absoluteUrl("/security-policy")}`, + `Canonical: ${absoluteUrl("/.well-known/security.txt")}`, + "Preferred-Languages: en", + `Expires: ${new Date(Date.now() + ONE_YEAR_MS).toISOString()}`, + "", + ].join("\n"); + return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } }); +}; diff --git a/apps/marketing/src/pages/404.astro b/apps/marketing/src/pages/404.astro new file mode 100644 index 0000000000000..3bc695c7b60e9 --- /dev/null +++ b/apps/marketing/src/pages/404.astro @@ -0,0 +1,44 @@ +--- +import Layout from "../layouts/Layout.astro"; +--- + + +
+

404

+

This page does not exist.

+

+ t3.codes is the only official T3 Code website. Download builds from this site, GitHub releases, + or the app stores. +

+
+
+ + + 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 494ad60ef94e4..5a3828f968865 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/[...locale]/download.astro @@ -1,11 +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 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: "", @@ -16,8 +24,26 @@ const imageProps = { } satisfies Partial>; --- - -
+ +
@@ -25,12 +51,12 @@ const imageProps = { -

Download T3 Code

+

{t.heading}

-
+
- - + +

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

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

@@ -109,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, + )}

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

Mobile

+

{t.mobile}

- iPhone & iPad + {t.iphoneIpad} App Store @@ -139,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/source-file-scroll.test.ts b/apps/mobile/src/features/files/source-file-scroll.test.ts deleted file mode 100644 index 98fc4b6a2ade8..0000000000000 --- a/apps/mobile/src/features/files/source-file-scroll.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { expect, it, vi } from "vite-plus/test"; - -import { createSourceLineScroll } from "./source-file-scroll"; - -const ROW_HEIGHT = 24; - -// Mirrors VirtualizedList.scrollToIndex without getItemLayout: indices past the highest -// measured cell fail, and unmeasured indices below it scroll to an averaged estimate. -function setup({ - lineCount = 1_203, - measuredThrough = 79, - rowHeight = (_index: number): number => ROW_HEIGHT, -} = {}) { - vi.useFakeTimers(); - vi.stubGlobal("requestAnimationFrame", (callback: () => void) => setTimeout(callback, 16)); - vi.stubGlobal("cancelAnimationFrame", (frame: ReturnType) => - clearTimeout(frame), - ); - const measured = new Set(); - const state = { - lineCount, - offset: 0, - measure(from: number, to: number) { - for (let index = from; index <= to; index += 1) measured.add(index); - }, - }; - state.measure(0, measuredThrough); - const offsetOf = (index: number) => { - let offset = 0; - for (let row = 0; row < index; row += 1) offset += rowHeight(row); - return offset; - }; - const averageItemLength = () => { - let total = 0; - for (const index of measured) total += rowHeight(index); - return total / measured.size; - }; - const scrollToIndex = vi.fn((params: { index: number }) => { - if (params.index < 0 || params.index >= state.lineCount) { - throw new Error(`scrollToIndex out of range: requested index ${params.index}`); - } - const highestMeasuredFrameIndex = Math.max(...measured); - if (params.index > highestMeasuredFrameIndex) { - scroll.onScrollToIndexFailed({ - index: params.index, - averageItemLength: averageItemLength(), - highestMeasuredFrameIndex, - }); - return; - } - state.offset = measured.has(params.index) - ? offsetOf(params.index) - : averageItemLength() * params.index; - }); - const scrollToOffset = vi.fn((params: { offset: number }) => { - state.offset = params.offset; - }); - const list = { - get props() { - return { data: { length: state.lineCount } }; - }, - scrollToIndex, - scrollToOffset, - }; - const scroll = createSourceLineScroll(); - return { - scroll, - list, - state, - offsetOf, - scrollToIndex, - scrollToOffset, - [Symbol.dispose]: () => { - vi.unstubAllGlobals(); - vi.useRealTimers(); - }, - }; -} - -it("lands on an already measured line", () => { - using t = setup(); - - t.scroll.scrollToLine(t.list, 40); - vi.runAllTimers(); - - expect(t.state.offset).toBe(t.offsetOf(40)); - expect(t.scrollToOffset).not.toHaveBeenCalled(); - expect(vi.getTimerCount()).toBe(0); -}); - -it("jumps near an unmeasured wrapped line, then lands on it once rows measure", () => { - using t = setup({ rowHeight: (index) => (index >= 80 ? ROW_HEIGHT * 3 : ROW_HEIGHT) }); - - t.scroll.scrollToLine(t.list, 899); - expect(() => vi.advanceTimersToNextTimer()).not.toThrow(); - expect(t.state.offset).toBeGreaterThan(0); - - // The render batch after the estimated jump measures rows past the target first, so - // the next scrollToIndex is accepted against an averaged estimate. - t.state.measure(900, 979); - vi.advanceTimersToNextTimer(); - expect(t.state.offset).not.toBe(t.offsetOf(899)); - - t.state.measure(820, 899); - vi.runAllTimers(); - - expect(t.state.offset).toBe(t.offsetOf(899)); - expect(vi.getTimerCount()).toBe(0); -}); - -it("stops retrying when the target never measures", () => { - using t = setup({ rowHeight: (index) => (index === 899 ? ROW_HEIGHT * 400 : ROW_HEIGHT) }); - - t.scroll.scrollToLine(t.list, 899); - expect(() => vi.runAllTimers()).not.toThrow(); - - expect(t.scrollToIndex.mock.calls.length).toBeLessThanOrEqual(10); - expect(vi.getTimerCount()).toBe(0); -}); - -it("ignores a line outside the document", () => { - using t = setup({ lineCount: 12 }); - - expect(() => t.scroll.scrollToLine(t.list, 899)).not.toThrow(); - vi.runAllTimers(); - expect(t.scrollToIndex).not.toHaveBeenCalled(); - expect(t.scrollToOffset).not.toHaveBeenCalled(); -}); - -it("drops a pending jump when the document shrinks or the jump is cancelled", () => { - using t = setup(); - - t.scroll.scrollToLine(t.list, 899); - vi.advanceTimersToNextTimer(); - t.state.lineCount = 12; - expect(() => vi.runAllTimers()).not.toThrow(); - - t.state.lineCount = 1_203; - t.scroll.scrollToLine(t.list, 899); - vi.advanceTimersToNextTimer(); - const calls = t.scrollToIndex.mock.calls.length; - t.scroll.cancel(); - t.state.measure(80, 999); - vi.runAllTimers(); - expect(t.scrollToIndex).toHaveBeenCalledTimes(calls); -}); - -it("a drag before the first frame cancels the jump", () => { - using t = setup(); - - t.scroll.scrollToLine(t.list, 40); - t.scroll.cancel(); - vi.runAllTimers(); - - expect(t.scrollToIndex).not.toHaveBeenCalled(); -}); diff --git a/apps/mobile/src/features/files/source-file-scroll.ts b/apps/mobile/src/features/files/source-file-scroll.ts deleted file mode 100644 index eb0f3dbd63f70..0000000000000 --- a/apps/mobile/src/features/files/source-file-scroll.ts +++ /dev/null @@ -1,66 +0,0 @@ -interface SourceLineList { - readonly props: { readonly data?: { readonly length: number } | null }; - scrollToIndex(params: { index: number; animated: boolean; viewPosition: number }): void; - scrollToOffset(params: { offset: number; animated: boolean }): void; -} - -const RETRY_DELAY_MS = 100; -const MAX_RETRIES = 5; - -// Wrapped rows have no getItemLayout. VirtualizedList fails a jump past its highest measured -// row and silently estimates an unmeasured row below it, so re-aim until the budget runs out. -export function createSourceLineScroll() { - let list: SourceLineList | null = null; - let target: number | null = null; - let retries = 0; - let cancelPending: (() => void) | null = null; - - const cancel = () => { - cancelPending?.(); - cancelPending = null; - target = null; - }; - - const retry = () => { - if (retries >= MAX_RETRIES) { - target = null; - return; - } - retries += 1; - const timer = setTimeout(attempt, RETRY_DELAY_MS); - cancelPending = () => clearTimeout(timer); - }; - - const attempt = () => { - cancelPending = null; - if (target === null || !list || target >= (list.props.data?.length ?? 0)) { - target = null; - return; - } - list.scrollToIndex({ index: target, animated: false, viewPosition: 0.3 }); - // A failed jump has already scheduled its retry. - if (cancelPending === null) retry(); - }; - - return { - scrollToLine(nextList: SourceLineList | null, index: number) { - cancel(); - list = nextList; - if (!Number.isInteger(index) || index < 0) return; - target = index; - retries = 0; - const frame = requestAnimationFrame(attempt); - cancelPending = () => cancelAnimationFrame(frame); - }, - onScrollToIndexFailed(info: { - index: number; - averageItemLength: number; - highestMeasuredFrameIndex: number; - }) { - if (info.index !== target || !list) return; - list.scrollToOffset({ offset: info.averageItemLength * info.index, animated: false }); - retry(); - }, - cancel, - }; -} diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 2fd52284106be..dd95959b30899 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -78,7 +78,9 @@ export function ThreadFileNavigatorPane(props: { ); return ( - + // Material drags target the first scroll view, so place content before the header. + + {fileTree} - {fileTree} ); } diff --git a/apps/mobile/src/features/files/useFileTreeEntries.test.tsx b/apps/mobile/src/features/files/useFileTreeEntries.test.tsx index 8d707fd379a83..e7f0f2d4136af 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.test.tsx +++ b/apps/mobile/src/features/files/useFileTreeEntries.test.tsx @@ -141,6 +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, + useContentBottomInset: () => 0, +})); vi.mock("@legendapp/list/react-native", () => ({ LegendList: (props: { data: ReadonlyArray; diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index bc1fe6af518a2..b5c86e3295c7b 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -15,9 +15,9 @@ import { useWorkspaceState } from "../../state/workspace"; import { FabScrollContext } from "./FabScrollContext"; import { updateFabScroll } from "./fab-scroll"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { WorkspaceTabsContext, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; -import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; +import { WorkspaceTabsContext, useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; +import { FLOATING_TABS, floatingTabsMetrics } from "../layout/workspace/floating-workspace-tabs"; +import { useEnabledNavigationTabs } from "../layout/workspace/navigation-tabs"; import { useAppearancePreferences, useAndroidFloatingTabs, diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx deleted file mode 100644 index 70c23920b7662..0000000000000 --- a/apps/mobile/src/features/home/HomeHeader.android.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { MenuAction } from "@react-native-menu/menu"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { ThreadListToolbar } from "./ThreadListToolbar"; -import type { HomeHeaderProps } from "./HomeHeader.types"; - -export type { HomeHeaderEnvironment } from "./HomeHeader.types"; - -function checkedMenuState(checked: boolean) { - return checked ? ("on" as const) : undefined; -} - -export function HomeHeader(props: HomeHeaderProps) { - const hasCustomListOptions = - props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; - const menuActions: MenuAction[] = [ - { - id: "environment", - title: "Environment", - subactions: [ - { - id: "environment:all", - title: "All environments", - state: checkedMenuState(props.selectedEnvironmentId === null), - }, - ...props.environments.map((environment) => ({ - id: `environment:${environment.environmentId}`, - title: environment.label, - state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId), - })), - ], - }, - ...(props.projects.length === 0 - ? [] - : ([ - { - id: "project", - title: "Project", - subactions: [ - { - id: "project:all", - title: "All projects", - state: checkedMenuState(props.selectedProjectKey === null), - }, - ...props.projects.map((project) => ({ - id: `project:${project.key}`, - title: project.label, - state: checkedMenuState(props.selectedProjectKey === project.key), - })), - ], - }, - ] satisfies MenuAction[])), - ]; - const handleMenuAction = (event: { nativeEvent: { event: string } }) => { - const id = event.nativeEvent.event; - if (id === "environment:all") { - props.onEnvironmentChange(null); - return; - } - - if (id.startsWith("environment:")) { - const environmentId = id.slice("environment:".length); - const environment = props.environments.find( - (candidate) => candidate.environmentId === environmentId, - ); - if (environment) { - props.onEnvironmentChange(environment.environmentId); - } - return; - } - - if (id === "project:all") { - props.onProjectChange(null); - return; - } - - if (id.startsWith("project:")) { - const projectKey = id.slice("project:".length); - if (props.projects.some((project) => project.key === projectKey)) { - props.onProjectChange(projectKey); - } - return; - } - }; - - return ( - <> - - handleMenuAction({ nativeEvent: { event } })} - onOpenEnvironments={props.onOpenEnvironments} - /> - - ); -} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx deleted file mode 100644 index 2062c518afef3..0000000000000 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { HomeHeaderProps } from "./HomeHeader.types"; - -export type { HomeHeaderEnvironment } from "./HomeHeader.types"; - -/** The iOS thread list lives in the workspace, whose WorkspaceToolbar owns its header and search. */ -export function HomeHeader(_props: HomeHeaderProps) { - return null; -} diff --git a/apps/mobile/src/features/home/HomeHeader.types.ts b/apps/mobile/src/features/home/HomeHeader.types.ts deleted file mode 100644 index 3c5f580f9eefb..0000000000000 --- a/apps/mobile/src/features/home/HomeHeader.types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; -import type { - HomeListFilterMenuEnvironment, - HomeListFilterMenuProject, -} from "./home-list-filter-menu"; - -export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; - -export interface HomeHeaderProps { - readonly environments: ReadonlyArray; - readonly projects: ReadonlyArray; - readonly searchQuery: string; - readonly selectedEnvironmentId: EnvironmentId | null; - readonly selectedProjectKey: string | null; - readonly onSearchQueryChange: (query: string) => void; - readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; - readonly onProjectChange: (projectKey: string | null) => void; - readonly onOpenEnvironments: () => void; - readonly onOpenSettings: () => void; - readonly onStartNewTask: () => void; -} diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 40d540104ee94..2268ac61e631b 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,13 +1,12 @@ import { uuidv4 } from "../../lib/uuid"; import { ThreadUndoNotice } from "./ThreadUndoNotice"; -import { Toolbar } from "../../components/ui/Toolbar"; import { use } from "react"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; import { useMemo, useState } from "react"; -import { Platform, useWindowDimensions } from "react-native"; +import { Platform } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { @@ -15,45 +14,40 @@ import { resolveScratchProjectScopeKey, } from "@t3tools/client-runtime/state/project-grouping"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; -import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; +import { useLaunchingThreads, 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 { useDeviceType, useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; -import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; -import { AppBar } from "../../components/ui/AppBar"; +import { + useAdaptiveWorkspaceLayout, + useWorkspacePaneState, +} from "../layout/AdaptiveWorkspaceLayout"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; -import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; -import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle"; -import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; import { openSettings } from "../settings/settings-navigation"; -import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; -import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; +import { useSelectWorkspaceTab } from "../layout/workspace/useSelectWorkspaceTab"; +import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; +import { WorkspaceListEmptyDetail } from "../layout/workspace/WorkspaceListEmptyDetail"; +import { WorkspaceListHeader } from "../layout/workspace/WorkspaceListHeader"; +import { buildHomeListFilterMenuItems } from "./home-list-filter-menu"; export function HomeRouteScreen() { - const { width: windowWidth } = useWindowDimensions(); const nativePrimaryColumn = use(NativePrimaryColumnContext); - const columnMetrics = useNativeColumnLayoutMetrics(); - const deviceType = useDeviceType(); - const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; - const { layout, panes, 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. const visible = useHomeRouteVisible(); - const threads = useAtomValueWhileVisible( - environmentThreadShells.navigationThreadShellsAtom, - visible, + const threads = useLaunchingThreads( + useAtomValueWhileVisible(environmentThreadShells.navigationThreadShellsAtom, visible), ); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); @@ -161,77 +155,37 @@ export function HomeRouteScreen() { if (layout.usesSplitView && !nativePrimaryColumn) { return ( - <> - [], - headerSearchBarOptions: undefined, - unstable_headerRightItems: () => [], - unstable_headerToolbarItems: () => [], - } - } - /> - {Platform.OS === "ios" ? ( - - - - ) : null} - {Platform.OS === "android" ? : null} - openSettings(navigation, "SettingsEnvironmentNew") - : undefined - } - onStartNewTask={ - Platform.OS === "android" && panes.primarySidebarVisible ? undefined : startNewTask - } - /> - + openSettings(navigation, "SettingsEnvironmentNew")} + onStartNewTask={startNewTask} + /> ); } return ( - + <> - {/* Screen options merge shallowly; restore the header after split view. */} - {workspace !== null && Platform.OS === "ios" ? null : ( - openSettings(navigation, "SettingsEnvironments"), - }), - headerShown: Platform.OS !== "android", - }} - /> - )} - openSettings(navigation, "SettingsEnvironments")} - onOpenSettings={() => selectTab("settings")} - onSearchQueryChange={setSearchQuery} - onStartNewTask={startNewTask} + search={{ + value: searchQuery, + onChangeText: setSearchQuery, + placeholder: "Search", + accessibilityLabel: "Search threads", + }} + filter={{ + customized: selectedEnvironmentId !== null || selectedProjectKey !== null, + items: buildHomeListFilterMenuItems({ + environments, + projects: projectFilterOptions, + selectedEnvironmentId, + selectedProjectKey, + onEnvironmentChange: setSelectedEnvironmentId, + onProjectChange: setSelectedProjectKey, + }), + }} /> ) { } function renderHomeScrollView(props: ScrollViewProps) { - // FlatList clones this with cells and ref; keep the marker inside their actual ScrollView. + // The list passes its content and ref in these props; keep the marker inside the real ScrollView. return ; } @@ -262,6 +263,8 @@ 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(); const contentSideInsets = usesNativeWorkspaceColumns @@ -325,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, }); }); @@ -346,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( @@ -682,7 +685,7 @@ export function HomeScreen(props: HomeScreenProps) { style={{ paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24), - paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0, + paddingTop: emptyTopInset, paddingLeft: 32 + (contentSideInsets?.left ?? 0), paddingRight: 32 + (contentSideInsets?.right ?? 0), }} @@ -745,7 +748,13 @@ export function HomeScreen(props: HomeScreenProps) { ); const v2ListEmpty = - hasSearchQuery && threadSearch.isPending ? undefined : hasSearchQuery ? ( + hasSearchQuery && threadSearch.isPending ? ( + + ) : hasSearchQuery ? ( 0 ? ( 0 - ? initialScrollOffset - (Platform.OS === "ios" ? insets.top : 0) - : undefined + initialScrollOffset > 0 ? initialScrollOffset - contentTopInset : undefined } showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" @@ -850,7 +855,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/ThreadListFilters.tsx b/apps/mobile/src/features/home/ThreadListFilters.tsx index a7d6894c35d91..196e6a86480b5 100644 --- a/apps/mobile/src/features/home/ThreadListFilters.tsx +++ b/apps/mobile/src/features/home/ThreadListFilters.tsx @@ -1,22 +1,22 @@ import { Pressable, ScrollView } from "react-native"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { ListFilterMenu } from "../workspace/ListFilterMenu"; -import type { HomeHeaderProps } from "./HomeHeader.types"; +import { ListFilterMenu } from "../layout/workspace/ListFilterMenu"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { + HomeListFilterMenuEnvironment, + HomeListFilterMenuProject, +} from "./home-list-filter-menu"; -export function ThreadListFilters( - props: Pick< - HomeHeaderProps, - | "environments" - | "projects" - | "selectedEnvironmentId" - | "selectedProjectKey" - | "onEnvironmentChange" - | "onProjectChange" - > & { - /** List padding the scroller bleeds through so pills start where row content does. */ - readonly gutter: number; - }, -) { +export function ThreadListFilters(props: { + readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; + /** List padding the scroller bleeds through so pills start where row content does. */ + readonly gutter: number; +}) { const selectedEnvironment = props.environments.find( (environment) => environment.environmentId === props.selectedEnvironmentId, ); @@ -27,7 +27,7 @@ export function ThreadListFilters( void; - readonly filterActions: MenuAction[]; - readonly filterCustomized: boolean; - readonly onFilterSelect: (id: string) => void; - readonly onOpenEnvironments: () => void; - readonly onRequestVisibility?: () => void; -} diff --git a/apps/mobile/src/features/home/ThreadUndoNotice.tsx b/apps/mobile/src/features/home/ThreadUndoNotice.tsx index 364873a3eadeb..caeb47a3c2195 100644 --- a/apps/mobile/src/features/home/ThreadUndoNotice.tsx +++ b/apps/mobile/src/features/home/ThreadUndoNotice.tsx @@ -10,8 +10,8 @@ import { useNativeLayoutMetrics, useNativeColumnLayoutMetrics, } from "../../native/native-layout-metrics"; -import { workspaceSearchBottomClearance } from "../workspace/workspace-tabs-state"; -import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +import { workspaceSearchBottomClearance } from "../layout/workspace/workspace-tabs-state"; +import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; import { dismissThreadUndoNotice, readThreadUndoNotice, diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index 58cf4138483a2..540831ad18f0e 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -1,4 +1,3 @@ -import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { createContext, use, useEffect, useState, type ReactNode } from "react"; import { Animated, Platform, Pressable, View } from "react-native"; @@ -6,11 +5,6 @@ import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { - brandTitleOffset, - CompactBrandTitle, - getCompactBrandHeaderOptions, -} from "../../components/CompactBrandTitle"; import { useDeadlinePassed } from "../../lib/clock"; import { useWorkspaceState } from "../../state/workspace"; import { @@ -159,24 +153,3 @@ export function WorkspaceConnectionTitle(props: { ); } - -export function getConnectionAwareBrandHeaderOptions(opts: { - readonly headerWidth: number; - readonly trailingItemCount?: number; - readonly onOpenEnvironments: () => void; - readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; -}): NativeStackNavigationOptions { - const maxWidth = Math.max(0, opts.headerWidth - 64 - 44 * (opts.trailingItemCount ?? 1)); - - return { - ...getCompactBrandHeaderOptions(opts.fallbackTitleStyle), - headerTitle: () => ( - } - maxWidth={maxWidth} - onPress={opts.onOpenEnvironments} - statusOffset={brandTitleOffset()} - /> - ), - }; -} diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index ac53e427d21f3..617c3fa0988ef 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,5 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import type { AppBarMenuItem } from "../../components/ui/AppBar.types"; + export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; readonly label: string; @@ -10,82 +12,57 @@ export interface HomeListFilterMenuProject { readonly label: string; } -type HomeListFilterMenuAction = { - readonly type: "action"; - readonly title: string; - readonly subtitle?: string; - readonly state?: "on" | "off"; - readonly onPress: () => void; -}; - -type HomeListFilterMenuSubmenu = { - readonly type: "submenu"; - readonly title: string; - readonly items: HomeListFilterMenuAction[]; -}; - -export interface HomeListFilterMenu { - readonly title: string; - readonly items: Array; -} - -export function buildHomeListFilterMenu(props: { +export function buildHomeListFilterMenuItems(props: { readonly environments: ReadonlyArray; readonly projects: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; -}): HomeListFilterMenu { - const items: Array = []; - - items.push({ - type: "submenu", - title: "Environment", - items: [ - { - type: "action", - title: "All environments", - subtitle: "Show threads from every environment", - state: props.selectedEnvironmentId === null ? "on" : "off", - onPress: () => props.onEnvironmentChange(null), - }, - ...props.environments.map((environment) => ({ - type: "action" as const, - title: environment.label, - state: - props.selectedEnvironmentId === environment.environmentId - ? ("on" as const) - : ("off" as const), - onPress: () => props.onEnvironmentChange(environment.environmentId), - })), - ], - }); +}): AppBarMenuItem[] { + const items: AppBarMenuItem[] = [ + { + id: "environment", + title: "Environment", + items: [ + { + id: "environment:all", + title: "All environments", + subtitle: "Show threads from every environment", + selected: props.selectedEnvironmentId === null, + onPress: () => props.onEnvironmentChange(null), + }, + ...props.environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + selected: props.selectedEnvironmentId === environment.environmentId, + onPress: () => props.onEnvironmentChange(environment.environmentId), + })), + ], + }, + ]; if (props.projects.length > 0) { items.push({ - type: "submenu", + id: "project", title: "Project", items: [ { - type: "action", + id: "project:all", title: "All projects", subtitle: "Show threads from every project", - state: props.selectedProjectKey === null ? "on" : "off", + selected: props.selectedProjectKey === null, onPress: () => props.onProjectChange(null), }, ...props.projects.map((project) => ({ - type: "action" as const, + id: `project:${project.key}`, title: project.label, - state: props.selectedProjectKey === project.key ? ("on" as const) : ("off" as const), + selected: props.selectedProjectKey === project.key, onPress: () => props.onProjectChange(project.key), })), ], }); } - return { - title: "Thread list options", - items, - }; + return items; } diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index a6c48086f2c85..532badd9b35cb 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -21,7 +21,7 @@ import type { StyleProp, ViewStyle, } from "react-native"; -import { Pressable, View } from "react-native"; +import { Platform, Pressable, View, useWindowDimensions } from "react-native"; import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -41,6 +41,7 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/ui/AppText"; +import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; @@ -49,6 +50,8 @@ const ACTION_CIRCLE_SIZE = 36; const ACTION_ICON_SIZE = 15; const COMPACT_ACTION_CIRCLE_SIZE = 28; const COMPACT_ACTION_ICON_SIZE = 13; +// At the default app text size, "Unarchive", the longest label, fills its slot at about 1.12x. +const ACTION_LABEL_MAX_FONT_SCALE = 1.1; const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; const THREAD_SWIPE_SPRING = { @@ -391,10 +394,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; } @@ -545,6 +548,15 @@ function SwipeActionButton(props: { } = props; const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; + const { fontScale } = useWindowDimensions(); + const labelLineHeight = useScaledTextRole("micro").lineHeight; + // Android scales lineHeight by the full font scale, past maxFontSizeMultiplier. + const labelTextStyle = { + lineHeight: + Platform.OS === "android" && fontScale > ACTION_LABEL_MAX_FONT_SCALE + ? (labelLineHeight * ACTION_LABEL_MAX_FONT_SCALE) / fontScale + : labelLineHeight, + }; const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-translation.value, 0); const entryProgress = interpolate( @@ -674,13 +686,19 @@ function SwipeActionButton(props: { /> + {/* An offset, not padding: padding would grow the box and move the circle at 1x. */} - + {props.label} diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 9313aef299881..18cd5363d3c0a 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -31,6 +31,7 @@ const state = vi.hoisted(() => ({ buttons?: { text: string; onPress?: () => void }[]; }[], afterRequest: undefined as (() => void) | undefined, + heldActions: new Map>(), })); vi.mock("react", () => ({ @@ -145,6 +146,7 @@ vi.mock("../../state/threads", () => ({ input: { threadId: string; orderKey?: string }; }) => { state.requests.push({ action, ...request }); + await state.heldActions.get(action); if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) { return AsyncResult.failure(Cause.fail(new Error("Thread operation denied"))); } @@ -200,6 +202,7 @@ beforeEach(() => { state.dialogs = []; state.alerts = []; state.afterRequest = undefined; + state.heldActions = new Map(); }); afterEach(() => { @@ -406,6 +409,23 @@ describe("thread action undo", () => { expect(state.archivedRefreshes).toEqual([primaryEnvironmentId]); }); + it("sends a different action while one is pending for the same thread", async () => { + const actions = useThreadListActions(); + const thread = makeThread(); + const settleResponse = Promise.withResolvers(); + state.heldActions.set("settle", settleResponse.promise); + + const pending = [ + actions.settleThread(thread), + actions.unsettleThread(thread), + actions.settleThread(thread), + ]; + settleResponse.resolve(); + + expect(await Promise.all(pending)).toEqual([true, true, true]); + expect(state.requests.map(({ action }) => action)).toEqual(["settle", "unsettle", "settle"]); + }); + it("restores pin placement and snooze after settling, only once", async () => { const thread = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z", @@ -532,13 +552,6 @@ describe("thread action undo", () => { await undo(); expect(state.requests.map(({ action }) => action)).toEqual(["unpin"]); }); - - // regression: #21401 restore archive and thread action undo - it("refreshes the archive after archiving on a settlement-capable server", async () => { - await useThreadListActions().archiveThread(makeThread()); - expect(state.requests.map(({ action }) => action)).toEqual(["archive"]); - expect(state.archivedRefreshes).toEqual([primaryEnvironmentId]); - }); }); describe("thread archive guard", () => { diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 248c436905311..9d44d971c1f03 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -91,7 +91,7 @@ function useThreadActionExecutor( const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); const settleMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false }); const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); - const inFlightThreadKeys = useRef(new Set()); + const latestPendingActions = useRef(new Map()); const executeAction = async ( action: ThreadListAction, @@ -100,11 +100,13 @@ function useThreadActionExecutor( ) => { if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false; const key = scopedThreadKey(thread.environmentId, thread.id); - if (inFlightThreadKeys.current.has(key)) { + // Drop only a repeat of the latest pending action; Settle → Un-settle → Settle must all send. + if (latestPendingActions.current.get(key)?.action === action) { return false; } - inFlightThreadKeys.current.add(key); + const pending = { action }; + latestPendingActions.current.set(key, pending); selectionHaptic(); const succeeded = await (async () => { if ( @@ -163,7 +165,7 @@ function useThreadActionExecutor( onCompleted?.(action, thread); return true; })(); - inFlightThreadKeys.current.delete(key); + if (latestPendingActions.current.get(key) === pending) latestPendingActions.current.delete(key); return succeeded; }; @@ -308,9 +310,7 @@ export function useThreadListActions(): { if (!canSnooze(thread, { now: new Date().toISOString() })) { Dialog.alert( "Could not snooze thread", - thread.hasPendingApprovals || - thread.hasPendingUserInput || - thread.pendingSubagentRequest != null + thread.hasPendingApprovals || thread.pendingSubagentRequest != null ? "This thread is waiting on you. Respond to the pending request before snoozing it." : "This thread is still starting a turn. Try again once it's running.", ); diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index e9e9e50d4527f..38b83e4c56bad 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -1,3 +1,4 @@ +import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; import { useAtomValue } from "@effect/atom-react"; import { uuidv4 } from "../../lib/uuid"; import { useNavigation } from "@react-navigation/native"; @@ -19,7 +20,6 @@ import { } from "@t3tools/shared/paletteSearch"; import { useEffect, useMemo, useRef, useState } from "react"; import { - FlatList, KeyboardAvoidingView, Modal, Pressable, @@ -71,8 +71,8 @@ import { parseActiveThreadPath, type HardwareKeyboardCommand } from "./hardwareK import { threadJumpIndex } from "./threadKeyboardShortcuts"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; import { openSettings } from "../settings/settings-navigation"; -import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; -import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; +import { useSelectWorkspaceTab } from "../layout/workspace/useSelectWorkspaceTab"; +import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; const PALETTE_COMMANDS: ReadonlyArray = [ "commandPalette", @@ -240,7 +240,7 @@ export function CommandPalette(props: { const pendingAction = useRef<(() => void) | null>(null); const closing = useRef(false); const inputRef = useRef(null); - const listRef = useRef>(null); + const listRef = useRef(null); const { width, height } = useWindowDimensions(); // The palette floats above every column, so it keeps itself inside the window's side insets // and to one side of a book fold. @@ -663,22 +663,23 @@ export function CommandPalette(props: { /> - item.key} - getItemLayout={(_, index) => ({ - length: ROW_HEIGHT + (index === firstMessageIndex ? SECTION_LABEL_HEIGHT : 0), - offset: - ROW_HEIGHT * index + - (firstMessageIndex !== -1 && index > firstMessageIndex - ? SECTION_LABEL_HEIGHT - : 0), - index, - })} - contentContainerClassName="pb-2" + getFixedItemSize={(_, index) => + ROW_HEIGHT + (index === firstMessageIndex ? SECTION_LABEL_HEIGHT : 0) + } + recycleItems + contentContainerStyle={{ paddingBottom: 8 }} ListEmptyComponent={ {search.isPending ? "Searching…" : "No results"} diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 135715f064924..98fba788bbd31 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -18,7 +18,7 @@ import { copyThreadReference } from "../../lib/copyThreadReference"; import { firstWorkspaceDetailOpen, workspaceHasColumns, -} from "../workspace/workspace-selection-store"; +} from "../layout/workspace/workspace-selection-store"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useThreadShell } from "../../state/entities"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; @@ -32,7 +32,7 @@ import { subscribeToHardwareKeyboardCommandRegistrations, type HardwareKeyboardCommand, } from "./hardwareKeyboardCommands"; -import { selectWorkspaceDetail } from "../workspace/workspace-detail-selection"; +import { selectWorkspaceDetail } from "../layout/workspace/workspace-detail-selection"; const EMPTY_COPY_FEEDBACK: GitActionProgress = { phase: "idle", diff --git a/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx b/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx new file mode 100644 index 0000000000000..f628e495f9e29 --- /dev/null +++ b/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx @@ -0,0 +1,84 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { KeyboardEventData } from "react-native-keyboard-controller"; +import { expect, it, vi } from "vite-plus/test"; + +const listeners = vi.hoisted(() => new Map void>>()); + +// Exercises the patched library source with only its native bindings replaced. +vi.mock("react-native-keyboard-controller/src/bindings", () => ({ + KeyboardEvents: { + addListener: (name: string, listener: (event: KeyboardEventData) => void) => { + const named = listeners.get(name) ?? new Set(); + named.add(listener); + listeners.set(name, named); + return { remove: () => named.delete(listener) }; + }, + }, + KeyboardControllerNative: { dismiss: () => {} }, +})); + +import { useKeyboardState } from "react-native-keyboard-controller/src/hooks/useKeyboardState"; +import { KeyboardController } from "react-native-keyboard-controller/src/module"; + +function emit(name: string, height: number) { + const event: KeyboardEventData = { + height, + duration: 0, + timestamp: 1, + target: 42, + type: "default", + appearance: "light", + }; + act(() => { + for (const listener of listeners.get(name) ?? []) listener(event); + }); +} + +function KeyboardState() { + const { isVisible, height } = useKeyboardState(); + return {JSON.stringify({ isVisible, height })}; +} + +// The library keeps keyboard state at module scope; every test starts and ends hidden. +function setup() { + emit("keyboardDidHide", 0); + return { [Symbol.dispose]: () => emit("keyboardDidHide", 0) }; +} + +function renderKeyboardState() { + const container = document.createElement("div"); + const root = createRoot(container); + act(() => root.render()); + return { container, [Symbol.dispose]: () => act(() => root.unmount()) }; +} + +// Android's syncKeyboardPosition repairs a nonanimated IME inset change with +// keyboardDidHide then keyboardDidShow, and no keyboardWillShow. +it("reports the keyboard shown after Android repairs the IME inset without keyboardWillShow", () => { + using _keyboard = setup(); + using screen = renderKeyboardState(); + + emit("keyboardDidShow", 376); + + expect({ + isVisible: KeyboardController.isVisible(), + height: KeyboardController.state().height, + hook: JSON.parse(screen.container.textContent ?? ""), + }).toEqual({ isVisible: true, height: 376, hook: { isVisible: true, height: 376 } }); +}); + +it("waits for the native hide before dismiss resolves after a didShow-only repair", async () => { + using _keyboard = setup(); + emit("keyboardDidShow", 376); + + let dismissed = false; + const dismissal = KeyboardController.dismiss().then(() => { + dismissed = true; + }); + await act(async () => {}); + + expect(dismissed).toBe(false); + emit("keyboardDidHide", 0); + await dismissal; +}); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 7196929b8a855..19ffe072fb58e 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, @@ -58,6 +57,7 @@ import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; +import { withKnownPullRequestSummary } from "../../state/use-thread-pr"; import { mobilePreferencesAtom } from "../../state/preferences"; import { DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, @@ -68,20 +68,20 @@ import { useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; -import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; +import { useEnabledNavigationTabs } from "./workspace/navigation-tabs"; +import { FLOATING_TABS, floatingTabsMetrics } from "./workspace/floating-workspace-tabs"; import { AndroidWorkspaceNavigation, FloatingWorkspaceTabs, ANDROID_WORKSPACE_RAIL_WIDTH, -} from "../workspace/AndroidWorkspaceNavigation"; +} from "./workspace/AndroidWorkspaceNavigation"; import { selectWorkspaceDetail, type WorkspaceDetail, type WorkspaceDetailSheet, -} from "../workspace/workspace-detail-selection"; -import { startOpeningDetail } from "../workspace/useSelectWorkspaceDetail"; -import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +} from "./workspace/workspace-detail-selection"; +import { startOpeningDetail } from "./workspace/useSelectWorkspaceDetail"; +import { useWorkspaceTabs } from "./workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { newTaskDestination } from "../threads/new-task-project-selection"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; @@ -102,22 +102,22 @@ import { import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { AndroidHingeObserver } from "../../native/AndroidHingeObserver"; import { WindowHingeContext } from "../../native/window-hinge-context"; -import { useAndroidWorkspaceRoot } from "../workspace/AndroidWorkspaceTabsProvider"; +import { useAndroidWorkspaceRoot } from "./workspace/AndroidWorkspaceTabsProvider"; import { dispatchRootStackReset, rootStackState } from "../../lib/root-stack-reset"; import { currentWorkspaceSelections, workspaceSelectionCheck, -} from "../workspace/workspace-selection-store"; +} from "./workspace/workspace-selection-store"; import { workspaceTabColumns, workspaceTabNavigationState, -} from "../workspace/workspace-tab-navigation"; -import type { WorkspaceTab } from "../workspace/workspace-tabs-state"; +} from "./workspace/workspace-tab-navigation"; +import type { WorkspaceTab } from "./workspace/workspace-tabs-state"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout"; -import { WorkspaceThreadSidebar } from "../workspace/WorkspaceThreadSidebar"; +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 @@ -190,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; @@ -244,10 +272,11 @@ function AdaptiveWorkspaceLayoutContent( const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); const [nativeInspectorWidthFraction, setNativeInspectorWidthFraction] = useState(0.4); - const nativeInspectorWidth = nativeInspectorColumnWidth( - viewportWidth, - nativeInspectorWidthFraction, - ); + const horizontalSafeArea = + (nativeMetrics?.safeArea.left ?? 0) + (nativeMetrics?.safeArea.right ?? 0); + const inspectorWidthFor = (fraction: number) => + nativeInspectorColumnWidth({ viewportWidth, horizontalSafeArea, fraction }); + const nativeInspectorWidth = inspectorWidthFor(nativeInspectorWidthFraction); const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState( null, ); @@ -290,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(); @@ -359,8 +362,8 @@ function AdaptiveWorkspaceLayoutContent( floatingTabs.row, floatingTabsEnabled, ]); - // A floating sidebar brings the rail with it. - const width = viewportWidth - (sidebarFloats ? 0 : navigationRailWidth); + // The rail keeps its room even while the sidebar floats; only the list pane overlays the page. + const width = viewportWidth - navigationRailWidth; // The sidebar stays mounted under a workspace page, so its list is ready on the way back. const shouldRenderPrimarySidebar = layout.usesSplitView; const fileInspector = useMemo( @@ -403,6 +406,7 @@ function AdaptiveWorkspaceLayoutContent( deriveWorkspacePaneLayout({ layout, viewportWidth: width, + horizontalSafeArea, nativeWorkspace, primarySidebarFloats: sidebarFloats, primarySidebarPreferredVisible: showPrimarySidebar, @@ -414,6 +418,7 @@ function AdaptiveWorkspaceLayoutContent( }), [ nativeInspectorWidth, + horizontalSafeArea, fileInspectorPreferredVisible, fileInspectorPreferredWidth, inspectorActive, @@ -425,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); @@ -504,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); @@ -518,7 +486,6 @@ function AdaptiveWorkspaceLayoutContent( setPrimarySidebarVisible(!preferredSidebarVisible); }; const revealPrimarySidebar = () => { - if (composerKeyboardActive) void KeyboardController.dismiss(); if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); } @@ -585,6 +552,7 @@ function AdaptiveWorkspaceLayoutContent( columns: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView), check: workspaceSelectionCheck, overlays, + seedPullRequest: withKnownPullRequestSummary, }, ); if (layout.variant === "medium") { @@ -664,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 && @@ -673,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( @@ -773,7 +721,7 @@ function AdaptiveWorkspaceLayoutContent( // panes land. A sliding sidebar uncovers the strip it is about to fill, or covers the content // until it is gone. const contentLeftTarget = sidebarFloats - ? 0 + ? navigationRailWidth : navigationRailWidth + (shouldRenderPrimarySidebar && !workspacePage && panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) @@ -804,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. @@ -823,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); } }), ); @@ -841,19 +785,18 @@ function AdaptiveWorkspaceLayoutContent( layout.listPaneWidth, layout.listPaneGap, panes.primarySidebarVisible, - keyboardHoldsSidebar, - keyboardHold, - keyboardProgress, - sidebarRestsOpen, renderedSidebarWidth, renderedRailWidth, settleListPane, - markSidebarSettled, + listPaneFloor, + listPaneCeiling, listPaneRevision, workspacePage, ]); const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value })); - const floatingSidebarAnimatedStyle = useAnimatedStyle(() => ({ left: renderedRailWidth.value })); + const floatingSidebarAnimatedStyle = useAnimatedStyle(() => ({ + left: sidebarFloats ? renderedRailWidth.value : 0, + })); const floatingSidebarOpen = sidebarFloats && panes.primarySidebarVisible; // Its own value, so a sidebar that was beside a list doesn't dim the page as it leaves. const scrimOpacity = useSharedValue(0); @@ -879,45 +822,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); @@ -925,8 +852,8 @@ function AdaptiveWorkspaceLayoutContent( Math.max( 0, viewportWidth - - (sidebarFloats ? 0 : renderedRailWidth.value) - - (workspacePage || sidebarFloats ? 0 : drawnSidebarWidth.value) - + renderedRailWidth.value - + (workspacePage || sidebarFloats ? 0 : renderedSidebarWidth.value) - renderedInspectorWidth.value, ), ); @@ -981,34 +908,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( - nativeInspectorColumnWidth(viewportWidth, width / viewportWidth) / viewportWidth, - ), - composerKeyboardActive, - keyboardHoldsSidebar, - setComposerEditing, - sidebarByOrientation, - workspacePageWidth, - }; + setNativeInspectorWidthFraction(inspectorWidthFor(width / viewportWidth) / viewportWidth), + }); + 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 = ( - + + + ); @@ -1045,10 +976,7 @@ function AdaptiveWorkspaceLayoutContent( > {shouldRenderNavigationRail ? ( - + ) : null} {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( @@ -1169,35 +1095,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/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx index 7a7d717259cb0..e04b72cb9685d 100644 --- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -6,7 +6,7 @@ import { NewThreadButton } from "../../components/NewThreadButton"; import { FloatingActionButton } from "../../components/ui/FloatingActionButton"; import { EmptyState } from "../../components/ui/EmptyState"; import { ScreenContent } from "../../components/ui/ScreenContent"; -import type { WorkspaceTab } from "../workspace/workspace-tabs-state"; +import type { WorkspaceTab } from "./workspace/workspace-tabs-state"; export function WorkspaceEmptyDetail(props: { readonly tab?: WorkspaceTab | "settings"; diff --git a/apps/mobile/src/features/layout/adaptive-workspace-context.ts b/apps/mobile/src/features/layout/adaptive-workspace-context.ts index 1002514100706..e4ef355412af5 100644 --- a/apps/mobile/src/features/layout/adaptive-workspace-context.ts +++ b/apps/mobile/src/features/layout/adaptive-workspace-context.ts @@ -1,10 +1,7 @@ -import type { WorkspaceTab } from "../workspace/workspace-tabs-state"; +import type { WorkspaceTab } from "./workspace/workspace-tabs-state"; import { createContext, use } from "react"; import type { InspectorContent } from "./inspector-panes"; -import type { - WorkspaceDetail, - WorkspaceDetailSheet, -} from "../workspace/workspace-detail-selection"; +import type { WorkspaceDetail, WorkspaceDetailSheet } from "./workspace/workspace-detail-selection"; import { deriveFileInspectorPaneLayout, deriveLayout, @@ -14,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; @@ -31,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; @@ -46,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 }); @@ -77,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, @@ -95,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.android.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx index 46592527501aa..2ee2000d400f0 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx @@ -21,7 +21,10 @@ import { use, type ComponentProps } from "react"; import { RoutedFormSheetContext } from "../../components/ui/RoutedFormSheetContext"; -import { projectStackPresentation } from "../../native/workspace-stack-projection"; +import { + isPresentedRoute, + projectStackPresentation, +} from "../../native/workspace-stack-projection"; import { WorkspaceColumnsRendererContext, WorkspacePresentationHostContext, @@ -37,8 +40,7 @@ function WorkspaceStackView(props: ViewProps) { const isOverlay = (route: Route) => { const descriptor = props.descriptors[route.key] ?? props.describe(route, true); - const presentation = descriptor.options.presentation; - return presentation !== undefined && presentation !== "card"; + return isPresentedRoute(descriptor.options); }; const projection = projectStackPresentation(props.state, isOverlay); const workspaceRoutes = projection.workspace; @@ -121,14 +123,19 @@ function WorkspaceStackNavigator({ descriptors={Object.fromEntries( Object.entries(descriptors).map(([key, descriptor]) => [ key, - descriptor.options.presentation === "formSheet" - ? { - ...descriptor, - render: () => ( - {descriptor.render()} - ), - } - : descriptor, + { + ...descriptor, + // Screens draw their bar in-tree; hiding the native one only after mount + // shows it for the first frame and leaves the page measured short. + options: { ...descriptor.options, headerShown: false }, + ...(descriptor.options.presentation === "formSheet" + ? { + render: () => ( + {descriptor.render()} + ), + } + : undefined), + }, ]), )} navigation={navigation} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index ab5d8baad876b..bac954d99f118 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -33,19 +33,20 @@ import { Split, type SplitHostCommands } from "react-native-screens"; import { SafeAreaView } from "react-native-safe-area-context"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { NATIVE_INSPECTOR_MIN_MAIN_WIDTH } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { WorkspaceTabsProvider, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +import { WorkspaceTabsProvider, useWorkspaceTabs } from "./workspace/WorkspaceTabsContext"; import { WorkspaceNativeTabs, WorkspaceSidebarTabsFooter, -} from "../workspace/WorkspaceNativeTabs.ios"; -import { workspaceUsesColumns } from "../workspace/workspace-tabs-state"; +} from "./workspace/WorkspaceNativeTabs.ios"; +import { workspaceUsesColumns } from "./workspace/workspace-tabs-state"; import { isSettingsRoute, settingsOpenIn } from "../settings/settings-navigation"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; -import { setNativelyPoppingWorkspaceRoute } from "../workspace/workspace-detail-selection"; +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"; @@ -70,6 +71,7 @@ import { partitionStackPresentations, reconcileStackScreens, type CollapsedSplitColumn, + isPresentedRoute, } from "../../native/workspace-stack-projection"; import { NativePrimaryColumnContext, @@ -88,6 +90,12 @@ const isPullRequestRoute = (route: Route) => route.name === "PullRequestFile" || route.name === "PullRequestFiles"; +// Every step of starting a task, from the project picker to the draft thread itself. +const isNewTaskRoute = (route: Route) => + route.name.startsWith("NewTask") || + route.name.startsWith("AddProject") || + (route.params !== undefined && "draft" in route.params && Boolean(route.params.draft)); + /** Beside the list, the first Settings page shows on one native screen that swaps its content. */ const SETTINGS_DETAIL_SCREEN = "settings-detail"; @@ -155,13 +163,7 @@ function ColumnScreen(props: { options={options} canGoBack={!props.primary} column={props.primary ? "primary" : "detail"} - onCompose={ - descriptor.route.params && - "draft" in descriptor.route.params && - descriptor.route.params.draft - ? undefined - : props.onCompose - } + onCompose={isNewTaskRoute(descriptor.route) ? undefined : props.onCompose} /> { - const presentation = props.descriptors[route.key]?.options.presentation; - return presentation !== undefined && presentation !== "card"; + return isPresentedRoute(props.descriptors[route.key]?.options); }); // The root's willDisappear reaches JS frames into the push, so hide the dock in the commit that // pushes; the native events still own every later change, including back gestures. @@ -565,8 +565,7 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean ), }; const overlays = partitionStackPresentations(projection.overlays, (route) => { - const presentation = props.descriptors[route.key]?.options.presentation; - return presentation !== undefined && presentation !== "card"; + return isPresentedRoute(props.descriptors[route.key]?.options); }); const routes = [baseRoute, ...overlays.map((group) => group[0]!)]; return ( @@ -645,8 +644,7 @@ function WorkspaceStackNavigator({ if (!nativeWorkspaceColumnsReady) return null; const settingsOpen = settingsOpenIn( state.routes.slice(0, state.index + 1).filter((route) => { - const presentation = descriptors[route.key]?.options.presentation; - return presentation === undefined || presentation === "card"; + return !isPresentedRoute(descriptors[route.key]?.options); }), ); return ( diff --git a/apps/mobile/src/features/layout/inspector-host.android.tsx b/apps/mobile/src/features/layout/inspector-host.android.tsx index 9537811916f07..be1f400d4d935 100644 --- a/apps/mobile/src/features/layout/inspector-host.android.tsx +++ b/apps/mobile/src/features/layout/inspector-host.android.tsx @@ -53,7 +53,8 @@ export function InspectorPanes(props: { position: "absolute", inset: 0, opacity: visible ? 1 : 0, - zIndex: visible ? 1 : 0, + // A lower zIndex puts the visible pane first in native order, where Material finds its scroll view. + zIndex: visible ? 0 : 1, }} > diff --git a/apps/mobile/src/features/layout/inspector-host.tsx b/apps/mobile/src/features/layout/inspector-host.tsx index bec36d336bd9c..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: { @@ -47,14 +54,8 @@ function PaneScreenContent(props: { return ( - {props.pane.nativeStack ? ( - content - ) : ( - <> - - {content} - - )} + + {content} ); diff --git a/apps/mobile/src/features/layout/inspector-panes.ts b/apps/mobile/src/features/layout/inspector-panes.ts index ab0366341c083..54dd8ef86fc67 100644 --- a/apps/mobile/src/features/layout/inspector-panes.ts +++ b/apps/mobile/src/features/layout/inspector-panes.ts @@ -5,8 +5,6 @@ export interface InspectorPane { readonly render: () => ReactNode; /** Opening one of these panes soon mounts the others too, so switching between them is instant. */ readonly prewarm?: boolean; - /** The pane is a native stack of its own, which draws its bar and insets its content itself. */ - readonly nativeStack?: boolean; } /** What a route shows in the workspace's inspector: its panes and the one on screen. */ diff --git a/apps/mobile/src/features/layout/nested-stacks.tsx b/apps/mobile/src/features/layout/nested-stacks.tsx new file mode 100644 index 0000000000000..7ee894db65928 --- /dev/null +++ b/apps/mobile/src/features/layout/nested-stacks.tsx @@ -0,0 +1,59 @@ +import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; +import type { ReactNode } from "react"; +import { Platform } from "react-native"; + +import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { createV5StackNavigator } from "../../native/createV5StackNavigator"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import type { AppScreenOptions } from "../../native/glass-header-options"; +import { pageRoute } from "./route-options"; + +/** + * Screen options for a stack nested in a sheet, so its pages push inside the sheet with the + * same bar as a sheet route. Android hides the native header: pages draw AppBar in-tree. + */ +export function useSheetStackOptions(): AppScreenOptions { + const theme = useUniwindTheme(); + const background = theme["--color-sheet-solid"]; + return { + ...pageRoute({ itemStyle: "standard" }), + animation: Platform.OS === "android" ? "default" : "slide_from_right", + contentStyle: { backgroundColor: background }, + gestureEnabled: true, + headerShown: Platform.OS !== "android", + headerStyle: { backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : background }, + headerTintColor: theme["--color-foreground"], + }; +} + +const Standalone = createV5StackNavigator<{ Standalone: undefined }>(); + +/** + * One native stack screen outside the app's navigation tree, for content that needs a native bar + * but is not a route: a popover, or a column the workspace hosts. Its bar is set from inside with + * AppBar or SheetHeader like any screen. + */ +export function StandaloneStack(props: { + readonly surface?: "screen" | "sidebar"; + readonly options?: AppScreenOptions; + readonly children: ReactNode; +}) { + const navigationTheme = useMobileNavigationTheme(props.surface); + return ( + + + + {() => props.children} + + + + ); +} diff --git a/apps/mobile/src/features/layout/route-options.ts b/apps/mobile/src/features/layout/route-options.ts new file mode 100644 index 0000000000000..b8b30e2d02a96 --- /dev/null +++ b/apps/mobile/src/features/layout/route-options.ts @@ -0,0 +1,158 @@ +import { Platform } from "react-native"; + +import { GLASS_HEADER_OPTIONS, type AppScreenOptions } from "../../native/glass-header-options"; +import { formSheetDetents } from "../../native/sheet-surface"; + +/** + * Route presets for the root stack. A route says what it is (a page, a sheet, a modal); the + * preset owns how each platform presents it, so screens never set presentation options. + * + * Android never shows the native stack header: every screen draws its bar in-tree with AppBar or + * SheetHeader, and the Android navigators hide the native one from the first frame. + */ + +type HeaderStyle = "glass" | "solid" | "none"; + +const SOLID_HEADER_OPTIONS: AppScreenOptions = { + headerBackButtonDisplayMode: "minimal", + headerBackTitle: "", + headerLargeTitle: false, + headerShadowVisible: false, + headerShown: true, + headerTitleStyle: { fontSize: 18, fontWeight: "800" }, + headerTransparent: false, +}; + +function headerOptions(header: HeaderStyle, itemStyle: "editor" | "standard"): AppScreenOptions { + if (header === "none") return { headerShown: false }; + if (header === "glass") { + // The editor style needs liquid glass, which GLASS_HEADER_OPTIONS already gates it on. + return itemStyle === "editor" + ? GLASS_HEADER_OPTIONS + : { ...GLASS_HEADER_OPTIONS, unstable_navigationItemStyle: undefined }; + } + return { + ...SOLID_HEADER_OPTIONS, + unstable_navigationItemStyle: + itemStyle === "editor" && Platform.OS === "ios" ? "editor" : undefined, + }; +} + +export interface PageRouteOptions { + readonly title?: string; + /** Glass bars let content scroll under them. Defaults to glass. */ + readonly header?: HeaderStyle; + /** iOS 26 groups a page's bar items like an editor; settings pages use the standard look. */ + readonly itemStyle?: "editor" | "standard"; + /** + * How the Duo minimizes the bar while scrolling. "never" for lists that stutter against the + * default on hardware; "onScrollUp" for conversations read backwards, like Messages. + */ + readonly duoMinimize?: "never" | "onScrollUp"; + /** The page is the root of its column, so it never shows Back. */ + readonly root?: boolean; +} + +/** A page in the workspace: pushed on compact windows, a column page on wide ones. */ +export function pageRoute(options: PageRouteOptions = {}): AppScreenOptions { + return { + ...headerOptions(options.header ?? "glass", options.itemStyle ?? "editor"), + ...(options.title === undefined ? undefined : { title: options.title }), + ...(options.duoMinimize + ? { unstable_headerMinimizationBehavior: options.duoMinimize } + : undefined), + ...(options.root ? { headerBackVisible: false } : undefined), + }; +} + +export interface SheetRouteOptions { + readonly title?: string; + /** Defaults to glass. "none" when the sheet draws no bar at all. */ + readonly header?: HeaderStyle; + /** The initial detent, or "full" for a sheet that always covers the window. */ + readonly detent: number | "full"; + /** The largest detent. Defaults to the full window on iOS, where the Duo needs it. */ + readonly maxDetent?: number; + /** Android's expanded detent when `maxDetent` is unset. */ + readonly androidMaxDetent?: number; + /** Android sizes the sheet to its content instead of using detents. */ + readonly androidFit?: boolean; + /** + * Where an Android formSheet can't host the screen: "card" pushes it, "fullScreen" presents it + * full screen (Android formSheets can't host a keyboard-driven composer). + */ + readonly android?: "sheet" | "card" | "fullScreen"; + /** The sheet spans the Duo's rail, and its content insets itself. */ + readonly spansRail?: boolean; + readonly itemStyle?: "editor" | "standard"; + readonly duoMinimize?: "never" | "onScrollUp"; +} + +function sheetDetents(options: SheetRouteOptions): number[] { + if (options.detent === "full") return [1]; + if (options.maxDetent !== undefined) return [options.detent, options.maxDetent]; + return formSheetDetents(options.detent, options.androidMaxDetent); +} + +/** A sheet over the workspace: a formSheet with a grabber that closes, never goes back. */ +export function sheetRoute(options: SheetRouteOptions): AppScreenOptions { + const header = headerOptions(options.header ?? "glass", options.itemStyle ?? "standard"); + const shared = { + ...header, + ...(options.title === undefined ? undefined : { title: options.title }), + ...(options.duoMinimize + ? { unstable_headerMinimizationBehavior: options.duoMinimize } + : undefined), + headerBackVisible: false, + gestureEnabled: true, + } satisfies AppScreenOptions; + if (Platform.OS === "android" && options.android === "card") { + return { ...shared, presentation: "card" }; + } + if (Platform.OS === "android" && options.android === "fullScreen") { + return { ...shared, presentation: "fullScreenModal" }; + } + return { + ...shared, + presentation: "formSheet", + sheetPrefersHorizontalBars: true, + sheetGrabberVisible: true, + sheetAllowedDetents: + Platform.OS === "android" && options.androidFit ? "fitToContents" : sheetDetents(options), + ...(Platform.OS === "android" ? { sheetCornerRadius: 28 } : undefined), + ...(options.spansRail ? { nativeContentInsetHorizontally: false } : undefined), + } as AppScreenOptions; +} + +export interface ModalRouteOptions { + readonly title?: string; + /** Defaults to "none": the screen draws its own SheetHeader or has no bar. */ + readonly header?: HeaderStyle; + readonly itemStyle?: "editor" | "standard"; + readonly duoMinimize?: "never" | "onScrollUp"; +} + +/** A full-screen modal over the workspace with a close button, not a back button. */ +export function modalRoute(options: ModalRouteOptions = {}): AppScreenOptions { + return { + ...headerOptions(options.header ?? "none", options.itemStyle ?? "standard"), + ...(options.title === undefined ? undefined : { title: options.title }), + ...(options.duoMinimize + ? { unstable_headerMinimizationBehavior: options.duoMinimize } + : undefined), + headerBackVisible: false, + presentation: "fullScreenModal", + }; +} + +/** Full-screen media (a device stream, a browser, an app) with no bar and no dismiss gesture. */ +export function immersiveRoute(options: { readonly hideSystemBars?: boolean } = {}) { + return { + presentation: "fullScreenModal", + headerShown: false, + gestureEnabled: false, + ...(options.hideSystemBars + ? { autoHideHomeIndicator: true, navigationBarHidden: true } + : undefined), + } satisfies AppScreenOptions; +} diff --git a/apps/mobile/src/features/layout/useOpenWorkspaceDetail.ts b/apps/mobile/src/features/layout/useOpenWorkspaceDetail.ts new file mode 100644 index 0000000000000..64966c35844ce --- /dev/null +++ b/apps/mobile/src/features/layout/useOpenWorkspaceDetail.ts @@ -0,0 +1,20 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; +import { Platform } from "react-native"; + +import { useSelectWorkspaceDetail } from "./workspace/useSelectWorkspaceDetail"; +import type { WorkspaceDetail, WorkspaceDetailSheet } from "./workspace/workspace-detail-selection"; + +/** + * Opens a thread or pull request from another page. Android shows one detail page at a time, so + * it selects the detail; iOS pushes it over the current page, so Back returns there. A `sheet` + * opens over the detail on Android and on its own over the current page on iOS. + */ +export function useOpenWorkspaceDetail() { + const navigation = useNavigation(); + const selectDetail = useSelectWorkspaceDetail(); + return (detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => { + if (Platform.OS === "android") selectDetail(detail, sheet); + else if (sheet) navigation.navigate(sheet.name, sheet.params); + else navigation.dispatch(StackActions.push(detail.name, detail.params)); + }; +} 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/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx similarity index 89% rename from apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx rename to apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx index 292a546506040..382336367e2fd 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx +++ b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx @@ -11,18 +11,18 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { useToolbarLayout } from "../../components/ui/useToolbarLayout"; -import { rootNavigationRef } from "../../lib/rootNavigation"; -import { useWorkspaceConnectionState } from "../../state/workspace"; +import { useAndroidControlSizing } from "../../../components/ui/useAndroidControlSizing"; +import { useToolbarLayout } from "../../../components/ui/useToolbarLayout"; +import { rootNavigationRef } from "../../../lib/rootNavigation"; +import { useWorkspaceConnectionState } from "../../../state/workspace"; import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors"; import { androidWorkspaceTabNavigationState, type AndroidWorkspaceNavigationTab, } from "./android-workspace-tabs"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import { useSoftKeyboardShowing } from "../keyboard/useSoftKeyboardShowing"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; +import { useSoftKeyboardShowing } from "../../keyboard/useSoftKeyboardShowing"; +import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; import { useAndroidWorkspaceRoot } from "./AndroidWorkspaceTabsProvider"; import { useNavigationTabOrder, useHiddenNavigationTabs } from "./navigation-tabs"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; @@ -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 ( ; diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceListEmptyDetail.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceListEmptyDetail.tsx new file mode 100644 index 0000000000000..f95491281fe3f --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/WorkspaceListEmptyDetail.tsx @@ -0,0 +1,51 @@ +import { Platform } from "react-native"; + +import { AppBar } from "../../../components/ui/AppBar"; +import { Toolbar } from "../../../components/ui/Toolbar"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { useWorkspacePaneState } from "../adaptive-workspace-context"; +import { WorkspaceEmptyDetail } from "../WorkspaceEmptyDetail"; + +/** + * The thread list's route in split view, where the list is the sidebar beside it: an empty + * detail. iOS clears the list's bar and keeps only New task. + */ +export function WorkspaceListEmptyDetail(props: { + readonly hasConnections: boolean; + readonly onAddConnection: () => void; + readonly onStartNewTask: () => void; +}) { + const { panes } = useWorkspacePaneState(); + return ( + <> + [], + headerSearchBarOptions: undefined, + unstable_headerRightItems: () => [], + unstable_headerToolbarItems: () => [], + }} + /> + + + + + + + ); +} diff --git a/apps/mobile/src/features/home/ThreadListToolbar.android.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.android.tsx similarity index 64% rename from apps/mobile/src/features/home/ThreadListToolbar.android.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceListHeader.android.tsx index babbf32859a80..82ce0f8630d4a 100644 --- a/apps/mobile/src/features/home/ThreadListToolbar.android.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.android.tsx @@ -1,16 +1,18 @@ import { useEffect, useState } from "react"; import { useNavigation, useNavigationState } from "@react-navigation/native"; -import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { BackHandler, Keyboard } from "react-native"; -import { AppBar } from "../../components/ui/AppBar"; -import { appBarMenuItemsFromActions } from "../../components/ui/menuActionItems"; -import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; -import { WorkspaceBrandTitle } from "../workspace/WorkspaceBrandTitle"; -import { useWorkspaceConnectionState } from "../../state/workspace"; -import type { ThreadListToolbarProps } from "./ThreadListToolbar.types"; +import { AppBar } from "../../../components/ui/AppBar"; +import { useWorkspaceConnectionState } from "../../../state/workspace"; +import { useHardwareKeyboardCommand } from "../../keyboard/hardwareKeyboardCommands"; +import { WorkspaceBrandTitle } from "./WorkspaceBrandTitle"; +import { useWorkspaceTabs } from "./WorkspaceTabsContext"; +import { + workspaceListFilterIcon, + type WorkspaceListHeaderProps, +} from "./WorkspaceListHeader.types"; -export function ThreadListToolbar(props: ThreadListToolbarProps) { +export function WorkspaceListHeader(props: WorkspaceListHeaderProps) { const navigation = useNavigation(); const workspace = useWorkspaceTabs(); const isTabActive = workspace?.isTabActive; @@ -18,11 +20,12 @@ export function ThreadListToolbar(props: ThreadListToolbarProps) { const listFocused = homeFocused && (workspace === null || workspace.state.selectedTab === "threads"); const connectionState = useWorkspaceConnectionState(); - const { onRequestVisibility, onSearchQueryChange } = props; + const { search, sidebar } = props; + const onSearchQueryChange = search.onChangeText; const [searchOpen, setSearchOpen] = useState(false); - const searching = searchOpen || props.searchQuery.length > 0; + const searching = searchOpen || search.value.length > 0; const openSearch = () => { - onRequestVisibility?.(); + sidebar?.onRequestVisibility(); setSearchOpen(true); return true; }; @@ -55,18 +58,18 @@ export function ThreadListToolbar(props: ThreadListToolbarProps) { return ( } search={{ - value: props.searchQuery, + value: search.value, onChangeText: onSearchQueryChange, - placeholder: "Search", - accessibilityLabel: "Search threads", + placeholder: search.placeholder, + accessibilityLabel: search.accessibilityLabel, active: searching, onOpen: openSearch, onClose: closeSearch, @@ -76,10 +79,8 @@ export function ThreadListToolbar(props: ThreadListToolbarProps) { ? [ { title: "Filter threads", - icon: props.filterCustomized - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle", - items: appBarMenuItemsFromActions(props.filterActions, props.onFilterSelect), + icon: workspaceListFilterIcon(props.filter.customized), + items: props.filter.items, }, ] : undefined diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.tsx new file mode 100644 index 0000000000000..840c0c9144f77 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.tsx @@ -0,0 +1,70 @@ +import { useRef } from "react"; +import type { SearchBarCommands } from "react-native-screens"; + +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { useHardwareKeyboardCommand } from "../../keyboard/hardwareKeyboardCommands"; +import { getConnectionAwareBrandHeaderOptions } from "./brand-header-options"; +import { createWorkspaceListHeaderItems } from "./workspace-list-header-items"; +import { + workspaceListFilterIcon, + type WorkspaceListHeaderProps, +} from "./WorkspaceListHeader.types"; + +export function WorkspaceListHeader(props: WorkspaceListHeaderProps) { + return props.sidebar ? : null; +} + +function SidebarColumnHeader( + props: WorkspaceListHeaderProps & { + readonly sidebar: NonNullable; + }, +) { + const { search, sidebar } = props; + const searchBarRef = useRef(null); + useHardwareKeyboardCommand("focusSearch", () => { + const focus = () => searchBarRef.current?.focus(); + if (!sidebar.visible) { + sidebar.onRequestVisibility(); + // Focus once the revealed sidebar has slid in. + setTimeout(focus, 240); + } else { + focus(); + } + return true; + }); + const items = createWorkspaceListHeaderItems({ + filter: { + accessibilityLabel: "Filter threads", + icon: workspaceListFilterIcon(props.filter.customized), + title: "Thread list options", + items: props.filter.items, + }, + onOpenSettings: props.onOpenSettings, + }); + + return ( + search.onChangeText(""), + onChangeText: (event) => search.onChangeText(event.nativeEvent.text), + }, + unstable_headerRightItems: () => items, + }} + /> + ); +} diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.types.ts b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.types.ts new file mode 100644 index 0000000000000..c367118cc93b1 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.types.ts @@ -0,0 +1,36 @@ +import type { AppBarMenuItem } from "../../../components/ui/AppBar.types"; + +/** The header of the workspace thread list, on Home and in the sidebar column. */ +export interface WorkspaceListHeaderProps { + /** Spoken for the brand title, such as "T3 Code, Threads". */ + readonly accessibilityLabel: string; + /** The connection status beside the brand opens environment settings. */ + readonly onOpenEnvironments: () => void; + readonly search: { + readonly value: string; + readonly onChangeText: (value: string) => void; + readonly placeholder: string; + readonly accessibilityLabel: string; + }; + readonly filter: { + readonly customized: boolean; + readonly items: ReadonlyArray; + }; + /** iOS: a settings button. Android reaches Settings from the workspace navigation. */ + readonly onOpenSettings?: () => void; + /** + * Set when the list is the workspace's sidebar column, which draws its own bar on iOS. Without + * it the iOS workspace root's toolbar draws the list's bar and search. + */ + readonly sidebar?: { + readonly width: number; + readonly visible: boolean; + readonly onRequestVisibility: () => void; + }; +} + +export function workspaceListFilterIcon(customized: boolean) { + return customized + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle"; +} diff --git a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx similarity index 94% rename from apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx index fbf99fb155f54..2247ea50c9a4a 100644 --- a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx @@ -17,19 +17,19 @@ import { import { NativeWorkspaceTabActivityContext, WorkspaceSlotOptionsContext, -} from "../../native/v5-workspace-context"; +} from "../../../native/v5-workspace-context"; import { useCompactWorkspaceTabs, useFloatingWorkspaceTabs, useSystemWorkspaceTabBar, } from "./useSystemWorkspaceTabBar"; -import { useControlTheme } from "../../components/ui/useControlTheme"; -import { useDeviceType } from "../../native/native-layout-metrics"; +import { useControlTheme } from "../../../components/ui/useControlTheme"; +import { useDeviceType } from "../../../native/native-layout-metrics"; import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "../AdaptiveWorkspaceLayout"; import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; -const PULL_REQUEST_ICON = require("../../../assets/icons/pull-request.png"); +const PULL_REQUEST_ICON = require("../../../../assets/icons/pull-request.png"); /** Retain each native controller tree; removing its root can leave RNS's stack empty. */ function RetainedWorkspaceTab(props: { @@ -85,7 +85,8 @@ export function WorkspaceNativeTabs(props: { const systemTabBar = useSystemWorkspaceTabBar(); const deviceType = useDeviceType(); const compactTabs = useCompactWorkspaceTabs(); - const { layout, panes } = useAdaptiveWorkspaceLayout(); + const { layout } = useAdaptiveWorkspaceLayout(); + const { panes } = useWorkspacePaneState(); // The tabs belong to the sidebar; over a page with the sidebar hidden they'd cover the composer. const hiddenWithSidebar = deviceType === "pad" && diff --git a/apps/mobile/src/features/workspace/WorkspaceNoTabs.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceNoTabs.tsx similarity index 80% rename from apps/mobile/src/features/workspace/WorkspaceNoTabs.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceNoTabs.tsx index 1db0f2529defe..561502dd54054 100644 --- a/apps/mobile/src/features/workspace/WorkspaceNoTabs.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceNoTabs.tsx @@ -1,7 +1,7 @@ import { useNavigation } from "@react-navigation/native"; import { View } from "react-native"; -import { EmptyState } from "../../components/ui/EmptyState"; -import { openSettings } from "../settings/settings-navigation"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { openSettings } from "../../settings/settings-navigation"; export function WorkspaceNoTabs() { const navigation = useNavigation(); diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx similarity index 82% rename from apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx index ec7a3bb8dcfe6..a03a36bc94762 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx @@ -1,4 +1,4 @@ -import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider"; +import { useAndroidFloatingTabs } from "../../settings/appearance/AppearancePreferencesProvider"; import { WorkspaceNoTabs } from "./WorkspaceNoTabs"; import { useEnabledNavigationTabs } from "./navigation-tabs"; import { NAVIGATION_TAB_ORDER } from "./navigation-tab-order"; @@ -6,22 +6,22 @@ import { WorkspaceExtraTab } from "./WorkspaceExtraTab"; import type { ReactNode } from "react"; import { View } from "react-native"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { AppBar } from "../../components/ui/AppBar"; -import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; -import { ScreenContent } from "../../components/ui/ScreenContent"; -import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { HomeRouteScreen } from "../home/HomeRouteScreen"; -import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; -import { PullRequestToolbar } from "../pull-requests/PullRequestToolbar.android"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { WorkspaceSettledContentWidthContext } from "../layout/workspace-content-width"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { AppBar } from "../../../components/ui/AppBar"; +import { WorkspaceEmptyDetail } from "../WorkspaceEmptyDetail"; +import { ScreenContent } from "../../../components/ui/ScreenContent"; +import { useAndroidControlSizing } from "../../../components/ui/useAndroidControlSizing"; +import { HomeRouteScreen } from "../../home/HomeRouteScreen"; +import { PullRequestsScreen } from "../../pull-requests/PullRequestsScreen"; +import { PullRequestToolbar } from "./PullRequestToolbar.android"; +import { useAdaptiveWorkspaceLayout } from "../AdaptiveWorkspaceLayout"; +import { WorkspaceSettledContentWidthContext } from "../workspace-content-width"; import { AndroidWorkspaceNavigation, FloatingWorkspaceTabs } from "./AndroidWorkspaceNavigation"; import { FLOATING_TABS } from "./floating-workspace-tabs"; import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; import { WorkspaceTabPanes } from "./WorkspaceTabPanes"; -import { WorkspaceAppBarScope } from "../../components/ui/WorkspaceAppBar.android"; -import { usePaneRole } from "../../components/ui/Pane"; +import { WorkspaceAppBarScope } from "../../../components/ui/WorkspaceAppBar.android"; +import { usePaneRole } from "../../../components/ui/Pane"; import { AndroidWorkspaceSidebar } from "./AndroidWorkspaceSidebar.android"; import type { WorkspaceTab } from "./workspace-tabs-state"; diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx similarity index 52% rename from apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx index 959797e21a76b..bc1c8e1f1f395 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx @@ -1,50 +1,43 @@ import { WorkspaceNoTabs } from "./WorkspaceNoTabs"; import { WORKSPACE_TAB_LABELS, useEnabledNavigationTabs } from "./navigation-tabs"; import { WorkspaceExtraTab } from "./WorkspaceExtraTab"; -import { SettingsRouteScreen } from "../settings/SettingsRouteScreen"; +import { WorkspaceSettingsList } from "../../settings/SettingsRouteScreen"; import { WorkspaceSectionToolbar } from "./WorkspaceSectionToolbar"; import { useNavigation } from "@react-navigation/native"; import { use } from "react"; -import { Platform, View, useWindowDimensions } from "react-native"; +import { View, useWindowDimensions } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; -import { CompactBrandTitle } from "../../components/CompactBrandTitle"; -import { AppBar } from "../../components/ui/AppBar"; -import { HeaderItemsWidthContext } from "../../native/header-items-width"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; -import { isDuoDevice } from "../../lib/device-type"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { CompactBrandTitle } from "../../../components/CompactBrandTitle"; +import { HeaderItemsWidthContext } from "../../../native/header-items-width"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { NativePrimaryColumnContext } from "../../../native/v5-workspace-context"; +import { isDuoDevice } from "../../../lib/device-type"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass"; import { useDeviceType, useNativeColumnLayoutMetrics, useNativeLayoutMetrics, -} from "../../native/native-layout-metrics"; -import { HomeRouteScreen } from "../home/HomeRouteScreen"; -import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; -import { WorkspaceConnectionTitle } from "../home/WorkspaceConnectionTitle"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; +} from "../../../native/native-layout-metrics"; +import { HomeRouteScreen } from "../../home/HomeRouteScreen"; +import { WorkspaceConnectionTitle } from "../../home/WorkspaceConnectionTitle"; +import { useAdaptiveWorkspaceLayout } from "../AdaptiveWorkspaceLayout"; +import { PullRequestsScreen } from "../../pull-requests/PullRequestsScreen"; import { WorkspaceTabsContext, WorkspaceTabsProvider, useWorkspaceTabs, } from "./WorkspaceTabsContext"; import { WorkspaceToolbar } from "./WorkspaceToolbar"; -import { - WORKSPACE_DOCK, - workspaceDockBottomClearance, - workspaceToolbarHeight, -} from "./workspace-tabs-state"; +import { WORKSPACE_DOCK, workspaceDockBottomClearance } from "./workspace-tabs-state"; import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs"; import { useCompactWorkspaceTabs, useSidebarWorkspaceTabs, useSystemWorkspaceTabBar, } from "./useSystemWorkspaceTabBar"; -import { openSettings } from "../settings/settings-navigation"; -import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; +import { openSettings } from "../../settings/settings-navigation"; const TAB_TITLES = WORKSPACE_TAB_LABELS; @@ -73,7 +66,6 @@ function WorkspaceRootTitle(props: { function WorkspaceRootContent() { const navigation = useNavigation(); - const selectTab = useSelectWorkspaceTab(); const { width: windowWidth } = useWindowDimensions(); const { layout, startNewTask } = useAdaptiveWorkspaceLayout(); const primaryColumn = use(NativePrimaryColumnContext); @@ -107,89 +99,58 @@ function WorkspaceRootContent() { const bottomClearance = layout.variant === "medium" && state.selectedTab === "usage" ? workspaceDockBottomClearance({ bottomInset: insets.bottom, keyboardHeight }) - : Platform.OS === "ios" && isDuoDevice(deviceType) + : isDuoDevice(deviceType) ? // The column's safe area already holds the Duo's search toolbar and keyboard. Math.max(insets.bottom, keyboardHeight) + 16 : compactTabs || deviceType === "pad" ? workspaceDockBottomClearance({ bottomInset: insets.bottom, keyboardHeight }) - : Platform.OS === "ios" - ? Math.max(insets.bottom, keyboardHeight) + - (systemTabBar || sidebarTabs || tab.searchMode !== "resting" - ? sidebarPicker - ? 80 - : 16 - : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16) - : keyboardHeight > 0 && tab.searchMode !== "resting" - ? keyboardHeight + 64 - : Math.max(insets.bottom, 12) + workspaceToolbarHeight(tab.searchMode) + 16; + : Math.max(insets.bottom, keyboardHeight) + + (systemTabBar || sidebarTabs || tab.searchMode !== "resting" + ? sidebarPicker + ? 80 + : 16 + : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16); const context = { ...workspace, bottomClearance }; return ( - {Platform.OS === "ios" ? ( - [] } + : {}), + unstable_headerLeftItems: () => [], + // Beside a detail column the tab bar names the tab and the detail header the page, + // so the sidebar keeps the brand. + headerTitle: + sidebarNavigation || (!settingsOpen && state.selectedTab !== "usage") + ? renderHeaderTitle : undefined, - contentStyle: undefined, - ...(enabled.length === 0 - ? { headerSearchBarOptions: undefined, unstable_headerRightItems: () => [] } - : {}), - unstable_headerLeftItems: () => [], - // Beside a detail column the tab bar names the tab and the detail header the page, - // so the sidebar keeps the brand. - headerTitle: - sidebarNavigation || (!settingsOpen && state.selectedTab !== "usage") - ? renderHeaderTitle - : undefined, - }} - /> - ) : ( - { - dispatchHardwareKeyboardCommand("focusSearch"); - }, - }, - ] - : []), - { - accessibilityLabel: "Settings", - icon: "gearshape", - onPress: () => selectTab("settings"), - }, - ]} - /> - )} + }} + /> {settingsOpen ? ( - + ) : enabled.length === 0 ? ( ) : state.selectedTab === "threads" ? ( @@ -203,12 +164,9 @@ function WorkspaceRootContent() { {deviceType !== "pad" && deviceType !== "phone" && sidebarPicker ? ( ) : null} - {/* Android's Settings list carries its own search in its app bar. */} - {enabled.length === 0 && !settingsOpen ? null : ( - settingsOpen - ? Platform.OS === "ios" - : state.selectedTab === "threads" || state.selectedTab === "pullRequests" - ) ? ( + {enabled.length === 0 && !settingsOpen ? null : settingsOpen || + state.selectedTab === "threads" || + state.selectedTab === "pullRequests" ? ( ) : ( @@ -219,11 +177,8 @@ function WorkspaceRootContent() { } export function WorkspaceRootScreen() { - const { layout } = useAdaptiveWorkspaceLayout(); const workspace = useWorkspaceTabs(); - return Platform.OS === "android" && layout.usesSplitView ? ( - - ) : workspace ? ( + return workspace ? ( ) : ( diff --git a/apps/mobile/src/features/workspace/WorkspaceSectionToolbar.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSectionToolbar.tsx similarity index 89% rename from apps/mobile/src/features/workspace/WorkspaceSectionToolbar.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceSectionToolbar.tsx index 611d6c8b2f2ed..1676a927ecc6f 100644 --- a/apps/mobile/src/features/workspace/WorkspaceSectionToolbar.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceSectionToolbar.tsx @@ -1,7 +1,7 @@ import { selectedNavigationTab, visibleNavigationTabs } from "./navigation-tab-order"; -import { Toolbar } from "../../components/ui/Toolbar"; -import { useControlTheme } from "../../components/ui/useControlTheme"; -import { useDeviceType } from "../../native/native-layout-metrics"; +import { Toolbar } from "../../../components/ui/Toolbar"; +import { useControlTheme } from "../../../components/ui/useControlTheme"; +import { useDeviceType } from "../../../native/native-layout-metrics"; import { NAVIGATION_TAB_ICONS, WORKSPACE_TAB_LABELS, @@ -12,7 +12,7 @@ import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; import { useCompactWorkspaceTabs, useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar"; -const PULL_REQUEST_ICON = require("../../../assets/icons/pull-request.png"); +const PULL_REQUEST_ICON = require("../../../../assets/icons/pull-request.png"); export function WorkspaceTabToolbarButtons() { const order = useNavigationTabOrder(); diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.android.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.android.tsx new file mode 100644 index 0000000000000..2f13950ebb379 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.android.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +/** Android draws the sidebar's bar in-tree, so the column needs no stack of its own. */ +export function WorkspaceSidebarColumn(props: { + readonly width: number; + readonly children: ReactNode; +}) { + return <>{props.children}; +} diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.tsx new file mode 100644 index 0000000000000..743adc0d78a36 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import { StyleSheet, View } from "react-native"; + +import { StandaloneStack } from "../nested-stacks"; +import { pageRoute } from "../route-options"; +import { getCompactBrandHeaderOptions } from "./brand-header-options"; + +const SIDEBAR_COLUMN_OPTIONS = { + ...pageRoute(), + ...getCompactBrandHeaderOptions({ fontSize: 18, fontWeight: "800" }), +}; + +/** The iOS sidebar column hosts its own stack so it gets a native bar with the brand title. */ +export function WorkspaceSidebarColumn(props: { + readonly width: number; + readonly children: ReactNode; +}) { + return ( + + + {props.children} + + + ); +} diff --git a/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.ios.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarTabs.ios.tsx similarity index 97% rename from apps/mobile/src/features/workspace/WorkspaceSidebarTabs.ios.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceSidebarTabs.ios.tsx index d46809c4bb1f7..0ebd5deb39132 100644 --- a/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.ios.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarTabs.ios.tsx @@ -1,7 +1,7 @@ import { selectedNavigationTab, visibleNavigationTabs } from "./navigation-tab-order"; import { requireNativeView } from "expo"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; -import { useControlTheme } from "../../components/ui/useControlTheme"; +import { useControlTheme } from "../../../components/ui/useControlTheme"; import { useNavigationTabOrder, useHiddenNavigationTabs } from "./navigation-tabs"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; diff --git a/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarTabs.tsx similarity index 100% rename from apps/mobile/src/features/workspace/WorkspaceSidebarTabs.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceSidebarTabs.tsx diff --git a/apps/mobile/src/features/workspace/WorkspaceTabPanes.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceTabPanes.tsx similarity index 100% rename from apps/mobile/src/features/workspace/WorkspaceTabPanes.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceTabPanes.tsx diff --git a/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx similarity index 98% rename from apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx index 6faa8c73d05ef..9ecb6fa933fa8 100644 --- a/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx @@ -2,7 +2,7 @@ import { RegistryContext, useAtomValue } from "@effect/atom-react"; import { AsyncResult, Atom } from "effect/reactivity"; import { createContext, use, useState, type Dispatch, type ReactNode } from "react"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { mobilePreferencesAtom } from "../../../state/preferences"; import { navigationCustomizationAvailable, resolveHiddenNavigationTabs, diff --git a/apps/mobile/src/features/workspace/WorkspaceThreadSidebar.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceThreadSidebar.tsx similarity index 80% rename from apps/mobile/src/features/workspace/WorkspaceThreadSidebar.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceThreadSidebar.tsx index 9b968908094c5..da1fb44a46046 100644 --- a/apps/mobile/src/features/workspace/WorkspaceThreadSidebar.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceThreadSidebar.tsx @@ -5,13 +5,13 @@ import type { import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useNavigation, useNavigationState } from "@react-navigation/native"; -import { scopedThreadKey } from "../../lib/scopedEntities"; -import { uuidv4 } from "../../lib/uuid"; -import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; -import { openSettings } from "../settings/settings-navigation"; -import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; -import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-requests"; +import { scopedThreadKey } from "../../../lib/scopedEntities"; +import { uuidv4 } from "../../../lib/uuid"; +import { usePendingTaskListActions } from "../../home/usePendingTaskListActions"; +import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "../adaptive-workspace-context"; +import { openSettings } from "../../settings/settings-navigation"; +import { ThreadNavigationSidebar } from "../../threads/ThreadNavigationSidebar"; +import { useOpenThreadPullRequests } from "../../threads/use-open-thread-pull-requests"; import { useSelectWorkspaceDetail } from "./useSelectWorkspaceDetail"; type Route = { readonly name: string; readonly params?: object }; @@ -42,14 +42,9 @@ function selectedThreadKey(state: { readonly index: number; readonly routes: rea /** The thread list in the workspace's sidebar column. */ export function WorkspaceThreadSidebar() { const navigation = useNavigation(); - const { - layout, - panes, - primarySidebarSearchQuery, - setPrimarySidebarSearchQuery, - revealPrimarySidebar, - openWorkspaceTab, - } = useAdaptiveWorkspaceLayout(); + const { layout, setPrimarySidebarSearchQuery, revealPrimarySidebar, openWorkspaceTab } = + useAdaptiveWorkspaceLayout(); + const { panes, primarySidebarSearchQuery } = useWorkspacePaneState(); const selectDetail = useSelectWorkspaceDetail(); const openThreadPullRequests = useOpenThreadPullRequests(); const selectedThread = useNavigationState(selectedThreadKey); diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx similarity index 87% rename from apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx index 00da5083cb3a3..3d27b096c1831 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx @@ -1,20 +1,21 @@ -import { useActiveNewTask } from "../../state/active-new-task"; -import { Toolbar } from "../../components/ui/Toolbar"; +import { useActiveNewTask } from "../../../state/active-new-task"; +import { Toolbar } from "../../../components/ui/Toolbar"; import { useFocusEffect, useIsFocused } from "@react-navigation/native"; import { use, useCallback, useEffect, useRef } from "react"; -import { Platform } from "react-native"; -import { isDuoDevice } from "../../lib/device-type"; -import { useDeviceType } from "../../native/native-layout-metrics"; +import { isDuoDevice } from "../../../lib/device-type"; +import { useDeviceType } from "../../../native/native-layout-metrics"; +import { createNativeHeaderMenu } from "../../../components/ui/nativeHeaderMenu.ios"; +import { useSettingsEnvironmentMenu } from "../../settings/components/SettingsEnvironmentFilterHeader"; import type { SearchBarCommands } from "react-native-screens"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; import { NativePrimaryColumnContext, NativeWorkspaceTabActivityContext, -} from "../../native/v5-workspace-context"; -import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +} from "../../../native/v5-workspace-context"; +import { useHardwareKeyboardCommand } from "../../keyboard/hardwareKeyboardCommands"; +import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "../AdaptiveWorkspaceLayout"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { shouldRestoreNativeSearch } from "./workspace-tabs-state"; import { useFloatingWorkspaceTabs, useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar"; @@ -39,15 +40,16 @@ export function WorkspaceToolbar(props: { const bottomSearch = duoSearch && sidebarTabs !== "picker"; // On the Duo, compose belongs to the threads tab, in the detail column's bar when unfolded. const nativeToolbarCompose = - Platform.OS === "ios" && deviceType === "duo-folded" && !workspace.settingsOpen && workspace.state.selectedTab === "threads" && activeNewTask === null; const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true; const focused = useIsFocused(); - const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const { layout, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const { panes } = useWorkspacePaneState(); const searchRef = useRef(null); + const settingsMenu = useSettingsEnvironmentMenu(); // The Settings list takes the selected tab's place, so search follows it. const selectedTab = workspace.settingsOpen ? "settings" : workspace.state.selectedTab; const tab = workspace.state.tabs[selectedTab]; @@ -181,9 +183,13 @@ export function WorkspaceToolbar(props: { : undefined, }} /> - {/* UIKit lays trailing items out from the edge inward. A trailing toolbar replaces the - header's own items, so an empty one would hide the Settings scope filter. */} - {workspace.settingsOpen && !foldedSearch ? null : ( + {/* UIKit lays trailing items out from the edge inward. */} + {workspace.settingsOpen ? ( + + {searchButton} + {createNativeHeaderMenu(settingsMenu)} + + ) : ( {searchButton} {nativeToolbarCompose && foldedSearch ? : null} diff --git a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts b/apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.test.ts similarity index 94% rename from apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts rename to apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.test.ts index 35513187eab3b..e88bb83eecc3d 100644 --- a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts +++ b/apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import { Hct, SchemeTonalSpot, argbFromHex, hexFromArgb } from "@material/material-color-utilities"; -import { MOBILE_THEME_IDS } from "../../lib/mobileTheme"; -import { getMobileThemeRuntimeVariables } from "../../lib/mobileThemeVariables"; -import { materialYouPaletteToMobileThemeVariables } from "../../lib/materialYouTheme.android"; -import type { MaterialYouPalette } from "../../lib/materialYouPalette"; +import { MOBILE_THEME_IDS } from "../../../lib/mobileTheme"; +import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables"; +import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme.android"; +import type { MaterialYouPalette } from "../../../lib/materialYouPalette"; import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors"; function luminance(hex: string) { diff --git a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts b/apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.ts similarity index 98% rename from apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts rename to apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.ts index 48216bfec4250..0e070f4bfaecc 100644 --- a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts +++ b/apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.ts @@ -4,7 +4,7 @@ import { themeColorWithAlpha, themeColorWithContrast, type MobileThemeVariables, -} from "../../lib/mobileTheme"; +} from "../../../lib/mobileTheme"; // A dark page swallows the native shadow, so the fill alone has to lift the pill off the page // and off the cards it floats over. diff --git a/apps/mobile/src/features/workspace/android-workspace-tabs.ts b/apps/mobile/src/features/layout/workspace/android-workspace-tabs.ts similarity index 91% rename from apps/mobile/src/features/workspace/android-workspace-tabs.ts rename to apps/mobile/src/features/layout/workspace/android-workspace-tabs.ts index e4d50b47708b5..63fde490b6fa6 100644 --- a/apps/mobile/src/features/workspace/android-workspace-tabs.ts +++ b/apps/mobile/src/features/layout/workspace/android-workspace-tabs.ts @@ -1,10 +1,10 @@ import type { NavigationState } from "@react-navigation/native"; -import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation"; +import { androidWorkspaceNavigationState } from "../../../lib/android-workspace-navigation"; import { isSettingsRoute, settingsNavigationState, settingsOpenIn, -} from "../settings/settings-navigation"; +} from "../../settings/settings-navigation"; import type { WorkspaceTab } from "./workspace-tabs-state"; import { rememberedSettingsPage, workspaceSelectionsIn } from "./workspace-tab-navigation"; diff --git a/apps/mobile/src/features/layout/workspace/brand-header-options.tsx b/apps/mobile/src/features/layout/workspace/brand-header-options.tsx new file mode 100644 index 0000000000000..d8330e4eb6e5e --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/brand-header-options.tsx @@ -0,0 +1,41 @@ +import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; +import { Platform } from "react-native"; + +import { brandTitleOffset, CompactBrandTitle } from "../../../components/CompactBrandTitle"; +import { WorkspaceConnectionTitle } from "../../home/WorkspaceConnectionTitle"; + +function renderCompactBrandTitle() { + return ; +} + +export function getCompactBrandHeaderOptions( + fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"], +): NativeStackNavigationOptions { + return { + headerTitle: renderCompactBrandTitle, + headerTitleStyle: fallbackTitleStyle, + title: "Threads", + unstable_headerLeftItems: undefined, + }; +} + +export function getConnectionAwareBrandHeaderOptions(opts: { + readonly headerWidth: number; + readonly trailingItemCount?: number; + readonly onOpenEnvironments: () => void; + readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; +}): NativeStackNavigationOptions { + const maxWidth = Math.max(0, opts.headerWidth - 64 - 44 * (opts.trailingItemCount ?? 1)); + + return { + ...getCompactBrandHeaderOptions(opts.fallbackTitleStyle), + headerTitle: () => ( + } + maxWidth={maxWidth} + onPress={opts.onOpenEnvironments} + statusOffset={brandTitleOffset()} + /> + ), + }; +} diff --git a/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts b/apps/mobile/src/features/layout/workspace/floating-workspace-tabs.test.ts similarity index 94% rename from apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts rename to apps/mobile/src/features/layout/workspace/floating-workspace-tabs.test.ts index 59dc131b1c870..bfb2feeb5be2c 100644 --- a/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts +++ b/apps/mobile/src/features/layout/workspace/floating-workspace-tabs.test.ts @@ -1,7 +1,7 @@ import { expect, it } from "vite-plus/test"; import { floatingTabsMetrics } from "./floating-workspace-tabs"; -import { resolveAndroidNavigationStyle } from "../../lib/androidNavigationStyle"; +import { resolveAndroidNavigationStyle } from "../../../lib/androidNavigationStyle"; it.each([ [undefined, "Samsung", false, "one-ui"], diff --git a/apps/mobile/src/features/workspace/floating-workspace-tabs.ts b/apps/mobile/src/features/layout/workspace/floating-workspace-tabs.ts similarity index 100% rename from apps/mobile/src/features/workspace/floating-workspace-tabs.ts rename to apps/mobile/src/features/layout/workspace/floating-workspace-tabs.ts diff --git a/apps/mobile/src/features/workspace/navigation-tab-order.test.ts b/apps/mobile/src/features/layout/workspace/navigation-tab-order.test.ts similarity index 100% rename from apps/mobile/src/features/workspace/navigation-tab-order.test.ts rename to apps/mobile/src/features/layout/workspace/navigation-tab-order.test.ts diff --git a/apps/mobile/src/features/workspace/navigation-tab-order.ts b/apps/mobile/src/features/layout/workspace/navigation-tab-order.ts similarity index 100% rename from apps/mobile/src/features/workspace/navigation-tab-order.ts rename to apps/mobile/src/features/layout/workspace/navigation-tab-order.ts diff --git a/apps/mobile/src/features/workspace/navigation-tabs.ts b/apps/mobile/src/features/layout/workspace/navigation-tabs.ts similarity index 87% rename from apps/mobile/src/features/workspace/navigation-tabs.ts rename to apps/mobile/src/features/layout/workspace/navigation-tabs.ts index b7b9b73ae6048..3472242dc4f56 100644 --- a/apps/mobile/src/features/workspace/navigation-tabs.ts +++ b/apps/mobile/src/features/layout/workspace/navigation-tabs.ts @@ -8,9 +8,9 @@ import { enabledNavigationTabs, type NavigationTab, } from "./navigation-tab-order"; -import type { AppSymbolName } from "../../components/ui/AppSymbol"; -import { mobilePreferencesAtom } from "../../state/preferences"; -import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; +import type { AppSymbolName } from "../../../components/ui/AppSymbol"; +import { mobilePreferencesAtom } from "../../../state/preferences"; +import { PullRequestGlyph } from "../../pull-requests/pullRequestGlyphs"; import { resolveWorkspaceTabOrder } from "./workspace-tabs-state"; export const WORKSPACE_TAB_LABELS = { diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts b/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts similarity index 97% rename from apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts rename to apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts index 92e157a45f041..dfc8e13d162c1 100644 --- a/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts +++ b/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts @@ -4,7 +4,7 @@ import { BackHandler, Keyboard, Platform } from "react-native"; import { resetAndroidWorkspace, returnToAndroidWorkspaceList, -} from "../../lib/android-workspace-navigation"; +} from "../../../lib/android-workspace-navigation"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import type { WorkspaceTab } from "./workspace-tabs-state"; diff --git a/apps/mobile/src/features/workspace/useSearchScrollRestoration.test.tsx b/apps/mobile/src/features/layout/workspace/useSearchScrollRestoration.test.tsx similarity index 100% rename from apps/mobile/src/features/workspace/useSearchScrollRestoration.test.tsx rename to apps/mobile/src/features/layout/workspace/useSearchScrollRestoration.test.tsx diff --git a/apps/mobile/src/features/workspace/useSearchScrollRestoration.ts b/apps/mobile/src/features/layout/workspace/useSearchScrollRestoration.ts similarity index 100% rename from apps/mobile/src/features/workspace/useSearchScrollRestoration.ts rename to apps/mobile/src/features/layout/workspace/useSearchScrollRestoration.ts diff --git a/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceDetail.ts similarity index 89% rename from apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts rename to apps/mobile/src/features/layout/workspace/useSelectWorkspaceDetail.ts index 59d1ac1fd8dcd..e487d9af071ba 100644 --- a/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts +++ b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceDetail.ts @@ -1,7 +1,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; -import { startOpeningEnvironmentThread } from "../../state/threads"; +import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; +import { startOpeningEnvironmentThread } from "../../../state/threads"; import { selectWorkspaceDetail, type WorkspaceDetail, diff --git a/apps/mobile/src/features/workspace/useSelectWorkspaceTab.ts b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceTab.ts similarity index 87% rename from apps/mobile/src/features/workspace/useSelectWorkspaceTab.ts rename to apps/mobile/src/features/layout/workspace/useSelectWorkspaceTab.ts index 8882a9f9d0b24..25d227b2736dc 100644 --- a/apps/mobile/src/features/workspace/useSelectWorkspaceTab.ts +++ b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceTab.ts @@ -1,6 +1,6 @@ import { Keyboard } from "react-native"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; +import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import type { WorkspaceTab } from "./workspace-tabs-state"; diff --git a/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts b/apps/mobile/src/features/layout/workspace/useSystemWorkspaceTabBar.ts similarity index 87% rename from apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts rename to apps/mobile/src/features/layout/workspace/useSystemWorkspaceTabBar.ts index f5a3f0af70346..3cf78b71048d3 100644 --- a/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts +++ b/apps/mobile/src/features/layout/workspace/useSystemWorkspaceTabBar.ts @@ -1,8 +1,8 @@ -import { isDuoDevice } from "../../lib/device-type"; +import { isDuoDevice } from "../../../lib/device-type"; import { Platform } from "react-native"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; -import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass"; +import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; +import { useDeviceType, useNativeLayoutMetrics } from "../../../native/native-layout-metrics"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { workspaceUsesColumns } from "./workspace-tabs-state"; diff --git a/apps/mobile/src/features/workspace/workspace-detail-selection.test.ts b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.test.ts similarity index 100% rename from apps/mobile/src/features/workspace/workspace-detail-selection.test.ts rename to apps/mobile/src/features/layout/workspace/workspace-detail-selection.test.ts diff --git a/apps/mobile/src/features/workspace/workspace-detail-selection.ts b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts similarity index 98% rename from apps/mobile/src/features/workspace/workspace-detail-selection.ts rename to apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts index fed18d6089f73..ad7f37f14036c 100644 --- a/apps/mobile/src/features/workspace/workspace-detail-selection.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts @@ -6,7 +6,7 @@ import type { Router, StackNavigationState, } from "@react-navigation/native"; -import { workspaceRoutesOf } from "../layout/workspace-overlay-routes"; +import { workspaceRoutesOf } from "../workspace-overlay-routes"; type RootParams = ReactNavigation.RootParamList; diff --git a/apps/mobile/src/features/layout/workspace/workspace-list-header-items.ts b/apps/mobile/src/features/layout/workspace/workspace-list-header-items.ts new file mode 100644 index 0000000000000..4ee54230835d7 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/workspace-list-header-items.ts @@ -0,0 +1,69 @@ +import type { + NativeStackHeaderItem, + NativeStackHeaderItemMenu, +} from "@react-navigation/native-stack"; + +import type { AppBarMenuItem } from "../../../components/ui/AppBar.types"; +import { withNativeGlassHeaderItem } from "../native-glass-header-items"; + +type NativeHeaderMenuItems = NativeStackHeaderItemMenu["menu"]["items"]; +type NativeHeaderIcon = NonNullable["icon"]>; + +function sfSymbolIcon(name: string): NativeHeaderIcon { + return { type: "sfSymbol", name: name as never }; +} + +function toNativeHeaderMenuItems(items: ReadonlyArray): NativeHeaderMenuItems { + return items.map((item) => + "items" in item + ? { + type: "submenu" as const, + label: item.title ?? "", + items: toNativeHeaderMenuItems(item.items), + } + : { + type: "action" as const, + label: item.title, + description: item.subtitle, + onPress: item.onPress, + state: item.selected ? ("on" as const) : undefined, + }, + ); +} + +/** The sidebar column's bar items, as native header items so the title can size around them. */ +export function createWorkspaceListHeaderItems(input: { + readonly filter: { + readonly accessibilityLabel: string; + readonly icon: string; + readonly title: string; + readonly items: ReadonlyArray; + }; + readonly onOpenSettings?: () => void; +}): NativeStackHeaderItem[] { + return [ + withNativeGlassHeaderItem({ + type: "menu", + axisBehavior: "horizontalOnly", + label: "", + accessibilityLabel: input.filter.accessibilityLabel, + icon: sfSymbolIcon(input.filter.icon), + menu: { + title: input.filter.title, + items: toNativeHeaderMenuItems(input.filter.items), + }, + }), + ...(input.onOpenSettings + ? [ + withNativeGlassHeaderItem({ + type: "button", + axisBehavior: "horizontalOnly", + label: "Settings", + accessibilityLabel: "Open settings", + icon: sfSymbolIcon("gearshape"), + onPress: input.onOpenSettings, + }), + ] + : []), + ]; +} diff --git a/apps/mobile/src/features/layout/workspace/workspace-list-insets.ts b/apps/mobile/src/features/layout/workspace/workspace-list-insets.ts new file mode 100644 index 0000000000000..52b8a3407158e --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/workspace-list-insets.ts @@ -0,0 +1,9 @@ +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass"; + +/** How far an empty workspace list keeps its message below a glass bar and its search. */ +export function useWorkspaceListEmptyTopInset(): number { + const insets = useSafeAreaInsets(); + return NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0; +} diff --git a/apps/mobile/src/features/workspace/workspace-selection-store.ts b/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts similarity index 90% rename from apps/mobile/src/features/workspace/workspace-selection-store.ts rename to apps/mobile/src/features/layout/workspace/workspace-selection-store.ts index 62adcd070059f..19d2653ddedfd 100644 --- a/apps/mobile/src/features/workspace/workspace-selection-store.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts @@ -3,27 +3,27 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Linking from "expo-linking"; import { AppState, Dimensions, Platform } from "react-native"; -import { environmentCatalog } from "../../connection/catalog"; -import { deriveLayout } from "../../lib/layout"; +import { environmentCatalog } from "../../../connection/catalog"; +import { deriveLayout } from "../../../lib/layout"; import { NATIVE_METRICS_TIMEOUT_MS, nativeWorkspaceColumnsSupportedFor, -} from "../../native/NativeWorkspaceColumns"; +} from "../../../native/NativeWorkspaceColumns"; import { currentNativeLayoutMetrics, firstNativeLayoutMetrics, -} from "../../native/native-layout-metrics"; -import { loadPreferences, savePreferencesPatch } from "../../persistence/imperative"; -import { rootStackState, type RootStackNavigation } from "../../lib/root-stack-reset"; -import { workspaceRoutesOf } from "../layout/workspace-overlay-routes"; -import type { WorkspaceSelections } from "../../persistence/mobile-preferences"; -import { appAtomRegistry } from "../../state/atom-registry"; -import { environmentPresentations } from "../../state/presentation"; +} from "../../../native/native-layout-metrics"; +import { loadPreferences, savePreferencesPatch } from "../../../persistence/imperative"; +import { rootStackState, type RootStackNavigation } from "../../../lib/root-stack-reset"; +import { workspaceRoutesOf } from "../workspace-overlay-routes"; +import type { WorkspaceSelections } from "../../../persistence/mobile-preferences"; +import { appAtomRegistry } from "../../../state/atom-registry"; +import { environmentPresentations } from "../../../state/presentation"; import { environmentThreadShells, startOpeningEnvironmentThread, threadEnvironment, -} from "../../state/threads"; +} from "../../../state/threads"; import { rememberedSettingsPage, workspaceSelectionsIn, diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts similarity index 96% rename from apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts rename to apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts index 1d30f2c02e6d7..3f217fd70c8bc 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { restoreSettingsLinkState } from "../settings/settings-navigation"; +import { restoreSettingsLinkState } from "../../settings/settings-navigation"; import { workspaceSelectionsIn, workspaceTabColumns, @@ -101,6 +101,11 @@ describe("opening a workspace tab", () => { "Settings", "SettingsEnvironments", ]); + expect( + names( + workspaceTabNavigationState(state, "settings", { settings: "SettingsMicrophone" }, columns), + ), + ).toEqual(["Home", "Settings", "SettingsVoice"]); }); it("keeps the route that is already open, so re-selecting a tab doesn't rebuild it", () => { diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts similarity index 91% rename from apps/mobile/src/features/workspace/workspace-tab-navigation.ts rename to apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts index 3674c52972e03..6a44e617e53ed 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts @@ -1,9 +1,9 @@ -import type { WorkspaceSelections } from "../../persistence/mobile-preferences"; +import type { WorkspaceSelections } from "../../../persistence/mobile-preferences"; import { SETTINGS_PAGE_TARGETS, type SettingsPageTarget, -} from "../settings/components/settings-page-targets"; -import { settingsNavigationState } from "../settings/settings-navigation"; +} from "../../settings/components/settings-page-targets"; +import { settingsNavigationState } from "../../settings/settings-navigation"; import type { WorkspaceTab } from "./workspace-tabs-state"; type RouteLike = { @@ -32,6 +32,7 @@ const REMEMBERED_SETTINGS_PAGES: ReadonlySet = new Set( ); export function rememberedSettingsPage(selections: WorkspaceSelections): SettingsPageTarget { + if (selections.settings === "SettingsMicrophone") return "SettingsVoice"; return selections.settings && REMEMBERED_SETTINGS_PAGES.has(selections.settings) ? (selections.settings as SettingsPageTarget) : DEFAULT_SETTINGS_PAGE; @@ -150,6 +151,7 @@ function detailRoute( tab: "threads" | "pullRequests", selections: WorkspaceSelections, check: WorkspaceSelectionCheck, + seedPullRequest?: (identity: Omit) => object, ): RouteLike | undefined { if (tab === "threads") { const draft = selections.threadsDraft; @@ -184,7 +186,11 @@ function detailRoute( (route) => route.name === "PullRequest" && samePullRequest(pullRequestSelection(route.params), pullRequest), - ) ?? { key: pullRequest.key, name: "PullRequest", params: identityOf(pullRequest) } + ) ?? { + key: pullRequest.key, + name: "PullRequest", + params: seedPullRequest?.(identityOf(pullRequest)) ?? identityOf(pullRequest), + } ); } @@ -204,6 +210,7 @@ export function workspaceTabNavigationState< readonly columns: boolean; readonly check: WorkspaceSelectionCheck; readonly overlays?: readonly RouteLike[]; + readonly seedPullRequest?: (identity: Omit) => object; }, ) { const overlays = options.overlays ?? []; @@ -221,7 +228,7 @@ export function workspaceTabNavigationState< const home = state.routes.find((route) => route.name === "Home") ?? { name: "Home" }; const detail = options.columns && tab !== "usage" - ? detailRoute(state, tab, selections, options.check) + ? detailRoute(state, tab, selections, options.check, options.seedPullRequest) : undefined; const routes = [...[home, ...(detail ? [detail] : [])].map(keyed), ...overlays]; return { ...state, index: routes.length - 1, routes }; diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts similarity index 92% rename from apps/mobile/src/features/workspace/workspace-tabs-state.test.ts rename to apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts index 06d4a49e05fee..f1c839630eb34 100644 --- a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts @@ -181,7 +181,7 @@ describe("workspace root navigation", () => { const mocks = vi.hoisted(() => ({ split: false, floating: false })); -vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ +vi.mock("../../settings/appearance/AppearancePreferencesProvider", () => ({ useAndroidFloatingTabs: () => mocks.floating, })); vi.mock("react-native", () => ({ @@ -192,7 +192,7 @@ vi.mock("react-native-safe-area-context", () => ({ SafeAreaInsetsContext: createContext({ top: 0, right: 0, bottom: 24, left: 0 }), useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 24, left: 0 }), })); -vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({ +vi.mock("../AdaptiveWorkspaceLayout", () => ({ useAdaptiveWorkspaceLayout: () => ({ layout: { usesSplitView: mocks.split }, startNewTask: () => undefined, @@ -211,30 +211,30 @@ vi.mock("./WorkspaceExtraTab", () => ({ return createElement("button", { onClick: () => setTab("Usage") }, tab); }, })); -vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); -vi.mock("../../components/ui/AppBar", () => ({ AppBar: () => null })); -vi.mock("../layout/WorkspaceEmptyDetail", () => ({ WorkspaceEmptyDetail: () => null })); -vi.mock("../../components/ui/ScreenContent", () => ({ +vi.mock("../../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../../components/ui/AppBar", () => ({ AppBar: () => null })); +vi.mock("../WorkspaceEmptyDetail", () => ({ WorkspaceEmptyDetail: () => null })); +vi.mock("../../../components/ui/ScreenContent", () => ({ ScreenContent: ({ children }: { children: ReactNode }) => createElement("div", null, children), })); -vi.mock("../../components/ui/useAndroidControlSizing", () => ({ +vi.mock("../../../components/ui/useAndroidControlSizing", () => ({ useAndroidControlSizing: () => ({ fabClearance: 64, scale: 1, fabSize: 56 }), })); -vi.mock("../home/HomeRouteScreen", () => ({ HomeRouteScreen: () => null })); -vi.mock("../pull-requests/PullRequestFilterMenuContext", () => ({ +vi.mock("../../home/HomeRouteScreen", () => ({ HomeRouteScreen: () => null })); +vi.mock("../../pull-requests/PullRequestFilterMenuContext", () => ({ PullRequestFilterMenuProvider: ({ children }: { children: ReactNode }) => createElement("div", null, children), })); -vi.mock("../pull-requests/PullRequestsScreen", () => ({ PullRequestsScreen: () => null })); -vi.mock("../pull-requests/PullRequestToolbar.android", () => ({ PullRequestToolbar: () => null })); +vi.mock("../../pull-requests/PullRequestsScreen", () => ({ PullRequestsScreen: () => null })); +vi.mock("./PullRequestToolbar.android", () => ({ PullRequestToolbar: () => null })); vi.mock("./AndroidWorkspaceNavigation", () => ({ AndroidWorkspaceNavigation: () => null, FloatingWorkspaceTabs: () => null, })); vi.mock("./WorkspaceNoTabs", () => ({ WorkspaceNoTabs: () => null })); -vi.mock("../../components/ui/Pane", () => ({ usePaneRole: () => "screen" })); +vi.mock("../../../components/ui/Pane", () => ({ usePaneRole: () => "screen" })); vi.mock("./AndroidWorkspaceSidebar.android", () => ({ AndroidWorkspaceSidebar: () => null })); -vi.mock("../../components/ui/WorkspaceAppBar.android", () => ({ +vi.mock("../../../components/ui/WorkspaceAppBar.android", () => ({ WorkspaceAppBarScope: ({ children }: { children: ReactNode }) => children, })); diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts similarity index 100% rename from apps/mobile/src/features/workspace/workspace-tabs-state.ts rename to apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts diff --git a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx index 8812c7347a5be..3a1b640b41933 100644 --- a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx @@ -1,9 +1,4 @@ import type { StaticScreenProps } from "@react-navigation/native"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { - addProjectRemoteSourceLabel, - parseAddProjectRemoteSource, -} from "@t3tools/client-runtime/operations/projects"; import { AddProjectRepositoryScreen } from "./AddProjectScreen"; @@ -15,14 +10,5 @@ type AddProjectRepositoryRouteParams = { export function AddProjectRepositoryRoute({ route, }: StaticScreenProps) { - const params = route.params ?? {}; - const source = Array.isArray(params.source) ? params.source[0] : params.source; - const title = addProjectRemoteSourceLabel(parseAddProjectRemoteSource(source)); - - return ( - <> - - - - ); + return ; } diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index b251310ef12f9..ab331d0149e15 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -4,7 +4,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeModule from "node:module"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -import { workspaceDetailRouter } from "../workspace/workspace-detail-selection"; +import { workspaceDetailRouter } from "../layout/workspace/workspace-detail-selection"; import type { NavigationState } from "@react-navigation/native"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -46,8 +46,8 @@ function loadRouters() { }; } -vi.mock("../workspace/useSelectWorkspaceDetail", async () => { - const { selectWorkspaceDetail } = await import("../workspace/workspace-detail-selection"); +vi.mock("../layout/workspace/useSelectWorkspaceDetail", async () => { + const { selectWorkspaceDetail } = await import("../layout/workspace/workspace-detail-selection"); return { useSelectWorkspaceDetail: () => (detail: Parameters[0]) => state.dispatch(selectWorkspaceDetail(detail)), diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index ddd6ec120d392..c41e1e20ba678 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,5 +1,5 @@ import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; +import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; import * as Dialog from "../../components/ui/Dialog"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { NavigationRow } from "../../components/ui/NavigationRow"; @@ -1424,12 +1424,11 @@ export function AddProjectDestinationScreen(props: { const originRoute = getActiveRoute(navigation); setIsSubmitting(true); - const projectId = ProjectId.make(uuidv4()); const title = inferProjectTitleFromPath(resolved.path); const startResult = await startProjectClone({ environmentId: environment.environmentId, input: { - projectId, + projectId: ProjectId.make(uuidv4()), title, createdAt: new Date().toISOString(), remoteUrl, @@ -1440,6 +1439,7 @@ export function AddProjectDestinationScreen(props: { setError(errorMessage(Cause.squash(startResult.cause))); } else { void suggestAppAccessForRemote(checkAppAccess, environment.environmentId, remoteUrl); + const { projectId } = startResult.value; // Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls. const project = await waitForProject( { environmentId: environment.environmentId, projectId }, diff --git a/apps/mobile/src/features/projects/suggestAppAccess.ts b/apps/mobile/src/features/projects/suggestAppAccess.ts index 9340d8dd836ad..9ead8af6ec956 100644 --- a/apps/mobile/src/features/projects/suggestAppAccess.ts +++ b/apps/mobile/src/features/projects/suggestAppAccess.ts @@ -5,7 +5,9 @@ import type { } from "@t3tools/contracts"; import { appSignInTargetForRemote } from "@t3tools/client-runtime/source-control-clients"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; -import { Alert, Linking } from "react-native"; +import { Linking } from "react-native"; + +import * as Dialog from "../../components/ui/Dialog"; /** * After a clone, suggests installing the forge app on the repository's owner when the server is @@ -24,7 +26,7 @@ export async function suggestAppAccessForRemote( const result = await check({ environmentId, input: target.input }); if (result._tag !== "Success" || result.value.status !== "not-covered") return; const owner = target.input.repository.split("/")[0] ?? target.input.repository; - Alert.alert( + Dialog.alert( `Give ${target.appSignIn.appName} access to ${owner}`, `It isn't installed there, so GitHub requests for ${target.input.repository} use your own login and its lower rate limit.`, [ diff --git a/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx b/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx index 0c4378671db90..0ab365fa52ad9 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx @@ -17,17 +17,15 @@ export function PullRequestAgentRow(props: { const row = ( 1} + menu={agent.chooseThread && !action.attributes.disabled} onPress={ - action.attributes.disabled || agent.threads.length > 1 - ? undefined - : () => agent.start(props.task) + action.attributes.disabled || agent.chooseThread ? undefined : () => agent.start(props.task) } /> ); - if (agent.threads.length <= 1 || action.attributes.disabled) return row; + if (!agent.chooseThread || action.attributes.disabled) return row; return ( + + + {actor.login} + + {actor.name && actor.name !== actor.login ? ( + + {actor.name} + + ) : null} + + ); + } + const { candidate } = completion; + return ( + <> + + {candidate.reference} + + + {candidate.title} + + + {STATE_LABEL[candidate.state]} + + + ); +} + +/** The `@`, `#` and `!` suggestions for a pull request text input, rendered just above it. */ +export function PullRequestCompletionSuggestions({ + completions, +}: { + readonly completions: ReturnType; +}) { + if (completions.kind === null) return null; + const copy = COPY[completions.kind]; + return ( + + {completions.candidates.length > 0 ? ( + + {completions.candidates.map((completion, index) => ( + completions.select(completion)} + className="flex-row items-center gap-2.5 border-border px-3.5 py-2.5 active:opacity-60" + style={{ + borderBottomWidth: + index === completions.candidates.length - 1 ? 0 : StyleSheet.hairlineWidth, + }} + > + + + ))} + + ) : ( + + + {copy[completions.isLoading ? "pending" : completions.failed ? "failed" : "none"]} + + + )} + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestControls.tsx index e816e53c2923c..ef8f59c74b079 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestControls.tsx @@ -3,6 +3,7 @@ import Animated from "react-native-reanimated"; import { PR_ROW_LAYOUT } from "./pullRequestAnimations"; import { useNavigation } from "@react-navigation/native"; import type { PullRequestDetail } from "@t3tools/contracts"; +import type { MenuAction } from "@react-native-menu/menu"; import type { ReactNode } from "react"; import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -128,7 +129,7 @@ export function PullRequestActionToolbar(props: { ).changeRequest; const threadLabel = agent.threads.length ? "Continue thread" : `Work on ${shortLabel}`; const primaryLabel = pendingReview ? `Submit review · ${draft.comments.length}` : threadLabel; - const chooseThread = agent.threads.length > 1; + const chooseThread = agent.chooseThread; const agentActions = agent.actions.filter((action) => action.task !== "ask"); const open = (mode: "review" | "comment") => navigation.navigate("PullRequestReview", { ...props.params, mode }); @@ -165,7 +166,7 @@ export function PullRequestActionToolbar(props: { > @@ -193,17 +194,6 @@ export function PullRequestActionToolbar(props: { ); } - const nativeThreadItems = threadMenuItems.map((item) => ( - agent.select(item.id)} - > - {item.title} - - )); const primaryControl = showPrimary ? ( !pendingReview && chooseThread ? ( - {nativeThreadItems} + {renderThreadMenuItems(threadMenuItems, agent.select)} ) : ( chooseThread && !action.disabled ? ( - {agent.threadActions(action.task).map((item) => ( - agent.select(item.id)} - > - {item.title} - - ))} + {renderThreadMenuItems(agent.threadActions(action.task), agent.select)} ) : ( ); } + +function renderThreadMenuItems(items: MenuAction[], select: (id: string) => void): ReactNode { + return items.map((item) => + item.subactions ? ( + + {renderThreadMenuItems(item.subactions, select)} + + ) : ( + item.id && select(item.id)} + > + {item.title} + + ), + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx b/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx index 59d2709a4f94a..2bee9303c9bf8 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx @@ -13,7 +13,7 @@ import { parseMarkdownDocument } from "../../lib/markdownDocument"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import type { FilePreviewItem } from "../../components/FilePreviewModal"; +import type { FilePreviewItem } from "../../components/ui/FilePreviewModal"; import { isBotBadgeImage } from "./botBadgeImages"; import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; diff --git a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx index b298094f74bea..e92debeea0f1d 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx @@ -1,4 +1,8 @@ -import { Toolbar } from "../../components/ui/Toolbar"; +import { + parsePullRequestAnchor, + pullRequestAnchorMatchesUrl, + resolvePullRequestReviewAnchor, +} from "@t3tools/shared/pullRequestAnchor"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { useAtomRefresh } from "@effect/atom-react"; import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; @@ -7,9 +11,9 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; -import type { PullRequestThreadComment } from "@t3tools/contracts"; +import { pullRequestConversationThreads, type PullRequestThreadComment } from "@t3tools/contracts"; import { useRef, useState } from "react"; -import { Platform, ScrollView, View } from "react-native"; +import { ScrollView, View, type ScrollViewInstance } from "react-native"; import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -17,7 +21,6 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/ import { AppBar } from "../../components/ui/AppBar"; import { Button } from "../../components/ui/Button"; import { GlassSurface } from "../../components/ui/GlassSurface"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; import { relativeTime } from "../../lib/time"; import { composerPullRequests } from "../../state/pull-requests"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -28,6 +31,8 @@ import { PullRequestCommentFooter } from "./PullRequestReactions"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; +import { PullRequestCompletionSuggestions } from "./PullRequestCompletionSuggestions"; +import { usePullRequestCompletions } from "./usePullRequestCompletions"; export function PullRequestDiscussionScreen({ route, @@ -55,8 +60,20 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss reportFailure: false, }); const loadMore = useAtomCommand(composerPullRequests.threadComments, { reportFailure: false }); - const thread = activity.data?.reviewThreads.find((thread) => thread.id === params.discussionId); + const thread = + activity.data && + pullRequestConversationThreads(activity.data).find( + (thread) => thread.id === params.discussionId, + ); + const filePath = thread?.path ?? null; const [body, setBody] = useState(""); + const completions = usePullRequestCompletions({ + environmentId: params.environmentId, + projectId: params.projectId, + value: body, + onChangeValue: setBody, + support: detail.data?.capabilities ?? null, + }); const [extra, setExtra] = useState([]); const [cursor, setCursor] = useState(); const [busy, setBusy] = useState(false); @@ -131,45 +148,52 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss [...extra, ...(thread?.comments ?? [])].map((comment) => [comment.id, comment]), ).values(), ].sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + const scrollRef = useRef(null); + const pendingTop = useRef(null); + const positionedComment = useRef(null); + const location = parsePullRequestAnchor(params.navigationUrl); + const anchor = + location?.kind === "timeline" + ? resolvePullRequestReviewAnchor(location.anchor, [], [{ comments }]) + : null; const canReply = permissions.canReply && Boolean(thread); const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve"; return ( - - { + pendingTop.current = null; + }} + > + navigation.goBack()} + actions={ + permissions.canResolve && thread + ? [ + { + accessibilityLabel: resolveLabel, + icon: thread.isResolved ? "arrow.uturn.backward" : "checkmark", + disabled: busy || activity.isPending, + onPress: () => void toggleResolved(), + }, + ] + : [] + } /> - {Platform.OS === "android" ? ( - navigation.goBack()} - actions={ - permissions.canResolve && thread - ? [ - { - accessibilityLabel: resolveLabel, - icon: thread.isResolved ? "arrow.uturn.backward" : "checkmark", - disabled: busy || activity.isPending, - onPress: () => void toggleResolved(), - }, - ] - : [] - } - /> - ) : null} - {permissions.canResolve && thread ? ( - - void toggleResolved()} - /> - - ) : null} { + if (pendingTop.current === null) return; + scrollRef.current?.scrollTo({ y: pendingTop.current, animated: false }); + pendingTop.current = null; + }} className="flex-1" contentInsetAdjustmentBehavior="automatic" keyboardShouldPersistTaps="handled" @@ -181,26 +205,48 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss {activity.isPending && !thread ? : null} {thread ? ( - - {thread.path} - {thread.line ? `:${thread.line}` : ""} - + {thread.path === null ? null : ( + + {thread.path} + {thread.line ? `:${thread.line}` : ""} + + )} {thread.isResolved ? "Resolved" : "Unresolved"} {thread.isOutdated ? " · Outdated" : ""} - + ))} +

+ ), +})); +vi.mock("../../state/entities", () => ({ + useLiveThreadLinkLabels: (markdown: string) => markdown, +})); +vi.mock("../../native/useOpenPullRequest", () => ({ + useOpenPullRequest: () => async (url: string) => { + harness.openedUrls.push(url); + return true; + }, +})); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ + navigate: (name: string, params?: object) => harness.navigate.push([name, params]), + }), +})); + +import { PendingUserInputCard } from "./PendingUserInputCard"; + +function setup(questionMarkdown: string) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + harness.navigate = []; + harness.openedUrls = []; + + const pendingUserInput = { + requestId: "request-1" as RuntimeRequestId, + responseCapability: "resumable", + questions: [ + { + id: "question-1", + header: "Review", + question: questionMarkdown, + options: [], + allowCustomAnswer: false, + }, + ], + } as unknown as PendingUserInput; + const container = document.createElement("div"); + const root = createRoot(container); + act(() => + root.render( + {} } as never} + cardCoverage={{ value: 0, set: () => {} } as never} + collapsed={false} + onToggleCollapsed={() => {}} + drafts={{}} + answers={null} + respondingUserInputId={null} + onSelectOption={() => {}} + onChangeCustomAnswer={() => {}} + onSubmit={async () => {}} + onDismiss={async () => {}} + />, + ), + ); + return { + async pressLink(label: string) { + const link = [...container.querySelectorAll("button")].find( + (button) => button.textContent === label, + ); + if (!link) throw new Error(`No link labelled ${label}`); + await act(async () => link.click()); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + }, + }; +} + +it("opens a workspace file link in a question in the thread's file viewer", async () => { + using screen = setup("Should I update [probe.md](docs/probe.md:12)?"); + + await screen.pressLink("probe.md"); + + expect(harness.openedUrls).toEqual([]); + expect(harness.navigate).toEqual([ + [ + "ThreadFile", + { environmentId: "env-1", threadId: "thread-1", path: ["docs", "probe.md"], line: "12" }, + ], + ]); +}); diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 3a2127ea2931c..70e5ac5c9b07a 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,12 +1,13 @@ import { Button } from "../../components/ui/Button"; import { QuestionAttachments } from "./QuestionAttachments"; -import type { EnvironmentId, RuntimeRequestId } from "@t3tools/contracts"; +import type { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; import { markdownProseDirection, textDirection, type TextDirection, } from "@t3tools/shared/textDirection"; +import { useNavigation } from "@react-navigation/native"; import { useRef } from "react"; import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; import Animated, { @@ -20,9 +21,15 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { ControlPill } from "../../components/ui/ControlPill"; import { cn } from "../../lib/cn"; +import { resolveMarkdownLinkPresentation } from "../../lib/markdownLinks"; import { SelectableMarkdownText } from "../../native/SelectableMarkdownText"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; +import { + fileRoutePathSegments, + resolveFileLinkPath, + resolveWorkspaceRelativeFilePath, +} from "../files/filePath"; import { useLiveThreadLinkLabels } from "../../state/entities"; import { isPendingUserInputOptionSelected, @@ -32,6 +39,8 @@ import type { PendingUserInput } from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly workspaceRoot: string | null; readonly canOperateThread: boolean; readonly pendingUserInput: PendingUserInput; readonly maxHeight: DerivedValue; @@ -69,11 +78,31 @@ function proseDirectionStyle(text: string, setDirection: TextDirection | undefin function QuestionText(props: { readonly markdown: string; readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly workspaceRoot: string | null; readonly setDirection: TextDirection | undefined; }) { const styles = useMarkdownPreviewStyles(); const markdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); - const openLink = useOpenPullRequest(); + const navigation = useNavigation(); + const openPullRequest = useOpenPullRequest(); + const openLink = (href: string) => { + const link = resolveMarkdownLinkPresentation(href); + if (link.kind !== "file") { + void openPullRequest(href); + return; + } + const path = resolveFileLinkPath(props.workspaceRoot, link.path); + if (path === null) return; + navigation.navigate("ThreadFile", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + path: fileRoutePathSegments( + resolveWorkspaceRelativeFilePath(props.workspaceRoot, path) ?? path, + ), + ...(link.line ? { line: String(link.line) } : {}), + }); + }; return ( void openLink(href)} + onLinkPress={openLink} /> ); } @@ -239,6 +268,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index d98488c755dda..2c369c1e5b7bc 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -14,8 +14,8 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useNavigation } from "@react-navigation/native"; -import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; -import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal"; import { useServerConfigs } from "../../state/entities"; import { appAtomRegistry } from "../../state/atom-registry"; import { diff --git a/apps/mobile/src/features/threads/SecretRequestCard.tsx b/apps/mobile/src/features/threads/SecretRequestCard.tsx index 19bf52ccd48c7..bdcef6f48ef7f 100644 --- a/apps/mobile/src/features/threads/SecretRequestCard.tsx +++ b/apps/mobile/src/features/threads/SecretRequestCard.tsx @@ -4,22 +4,34 @@ import { secretRequestAnswerInput, secretRequestDisplay, secretRequestFailureMessage, + secretRequestLink, secretRequestPrivacyNote, + secretRequestReference, + secretRequestSources, type SecretRequestItem, + type SecretRequestReply, } from "@t3tools/client-runtime/secret-request"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts"; +import type { + EnvironmentId, + OrchestrationV2ProjectedTurnItem, + SecretProviderId, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { useRef, useState } from "react"; import { View, type ColorValue } from "react-native"; import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; +import { SecretTextInput } from "../../components/ui/SecretTextInput"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { Button } from "../../components/ui/Button"; +import { Chip } from "../../components/ui/Chip"; const LOCK_SYMBOL: AppSymbolName = { ios: "lock", android: "lock" }; const PRIVATE_SYMBOL: AppSymbolName = { ios: "checkmark.shield", android: "lock" }; @@ -69,15 +81,18 @@ function PendingSecretRequestForm(props: { reportFailure: false, reportDefect: false, }); + const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + const sources = secretRequestSources(config, config?.settings.secretProviders ?? {}); + const [sourceId, setSourceId] = useState(null); + const source = sources.find((candidate) => candidate.id === sourceId) ?? null; const [secret, setSecret] = useState(""); + const [reference, setReference] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); // Submit then a tap can both run before a re-render; this guard is synchronous. const inFlight = useRef(false); - const send = async ( - reply: { readonly type: "save"; readonly secret: string } | { readonly type: "decline" }, - ) => { + const send = async (reply: SecretRequestReply) => { const input = secretRequestAnswerInput(item, reply); if (input === null || inFlight.current) return; inFlight.current = true; @@ -89,12 +104,24 @@ function PendingSecretRequestForm(props: { }); if (result._tag === "Success") { setSecret(""); + setReference(""); return; } if (!isAtomCommandInterrupted(result)) { pipe(squashAtomCommandFailure(result), secretRequestFailureMessage, setError); } }; + const save = () => { + if (source === null) { + void send({ type: "save", secret }); + } else if (secretRequestReference(source, reference) === undefined) { + setError(source.invalidReferenceMessage); + } else { + void send({ type: "reference", source, reference }); + } + }; + const draft = source === null ? secret : reference; + const link = secretRequestLink(item); return ( @@ -106,22 +133,79 @@ function PendingSecretRequestForm(props: { ) : null} - void send({ type: "save", secret })} - /> + {link !== null ? ( + + + ), View: ({ children }: { children: ReactNode }) =>
{children}
, })); +vi.mock("expo-clipboard", () => ({ setStringAsync: vi.fn() })); +vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null })); vi.mock("../../components/ui/AppText", () => ({ AppText: ({ children }: { children: ReactNode }) => {children}, })); @@ -27,18 +40,97 @@ import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; const DAY_MS = 24 * 60 * 60 * 1000; -function failedThread(resetAt: string): EnvironmentThreadShell { +function failedThread( + resetAt: string | null, + lastErrorCode?: string, + lastErrorClass = "usage_limit", +): EnvironmentThreadShell { return { id: "thread-1", updatedAt: new Date(0).toISOString(), snoozedUntil: null, latestRun: { runId: "run-1", completedAt: new Date(0).toISOString() }, limitRecovery: null, - runtime: { status: "failed", lastErrorClass: "usage_limit", usageLimitResetAt: resetAt }, + runtime: { status: "failed", lastErrorClass, lastErrorCode, usageLimitResetAt: resetAt }, } as unknown as EnvironmentThreadShell; } describe("UsageLimitRecoveryCard", () => { + // Older servers report ChatGPT sharing limits as provider errors and may store a reset. + it.each([ + [CHATGPT_USAGE_LIMIT_CODE, "usage_limit", null, ["Manage usage"]], + [CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, "usage_limit", null, ["Copy request for admin"]], + [CHATGPT_USAGE_LIMIT_CODE, "provider_error", null, ["Manage usage"]], + [ + CHATGPT_USAGE_LIMIT_CODE, + "usage_limit", + new Date(DAY_MS).toISOString(), + ["Manage usage", "Resume at reset", "Snooze until reset"], + ], + ])("shows one ChatGPT notice for %s (%s, reset %s)", (code, errorClass, resetAt, actions) => { + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + expect(container.textContent).toContain("ChatGPT usage limit reached"); + expect(container.textContent).not.toContain("did not report a reset time"); + expect([...container.querySelectorAll("button")].map((button) => button.textContent)).toEqual( + actions, + ); + }); + + it("drops a failed copy's error once a retry copies the request", async () => { + vi.mocked(Clipboard.setStringAsync) + .mockRejectedValueOnce(new Error("denied")) + .mockResolvedValueOnce(true); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + const copy = () => container.querySelector("button")!.click(); + await act(async () => copy()); + expect(container.textContent).toContain("Could not copy the request"); + await act(async () => copy()); + expect(container.textContent).toContain("Request copied"); + expect(container.textContent).not.toContain("Could not copy the request"); + }); + + it("ignores a second copy while the first is still in flight", async () => { + const first = Promise.withResolvers(); + vi.mocked(Clipboard.setStringAsync).mockReset().mockReturnValueOnce(first.promise); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + const copy = () => container.querySelector("button")!.click(); + act(() => copy()); + act(() => copy()); + expect(Clipboard.setStringAsync).toHaveBeenCalledTimes(1); + await act(async () => first.resolve(true)); + expect(container.textContent).toContain("Request copied"); + }); + it("disables Snooze once a reset more than 24.8 days away passes", () => { using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; vi.useFakeTimers({ now: 0 }); diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 791dc2ab5d847..201fc78234f5a 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -1,11 +1,18 @@ -import { Button } from "../../components/ui/Button"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { limitRecoveryOwnsSnooze } from "@t3tools/client-runtime/state/thread-execution"; import type { EnvironmentId } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; +import { + CHATGPT_USAGE_LIMIT_CODE, + CHATGPT_USAGE_URL, + CHATGPT_WORKSPACE_ADMIN_REQUEST, + CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, +} from "@t3tools/shared/usageLimits"; +import * as Clipboard from "expo-clipboard"; import { useState } from "react"; -import { View } from "react-native"; +import { Linking, Pressable, View } from "react-native"; +import { ProviderIcon } from "../../components/ProviderIcon"; import { AppText as Text } from "../../components/ui/AppText"; import { useDeadlinePassed } from "../../lib/clock"; import { threadEnvironment } from "../../state/threads"; @@ -23,6 +30,7 @@ export function UsageLimitRecoveryCard({ const [error, setError] = useState(null); const resetAt = thread.runtime?.usageLimitResetAt ?? null; const canSchedule = + thread.settledOverride !== "settled" && resetAt !== null && Date.parse(resetAt) > Date.parse(thread.latestRun?.completedAt ?? thread.updatedAt); const resetPassed = useDeadlinePassed(resetAt === null ? null : Date.parse(resetAt)); @@ -30,19 +38,40 @@ export function UsageLimitRecoveryCard({ const recovery = thread.limitRecovery; const scheduled = recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume; + const displayAt = + canSchedule && + scheduled && + resetAt && + thread.snoozedUntil && + Date.parse(thread.snoozedUntil) > Date.parse(resetAt) + ? thread.snoozedUntil + : resetAt; + const [copied, setCopied] = useState(false); + const [actionPending, setActionPending] = useState(false); + const code = thread.runtime?.lastErrorCode; + // OpenAI's partner guidelines specify the ChatGPT notice and its action. + const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE; + const chatGptLimit = workspaceLimit || code === CHATGPT_USAGE_LIMIT_CODE; if ( thread.runtime?.status !== "failed" || - thread.runtime.lastErrorClass !== "usage_limit" || + // Older servers report ChatGPT sharing limits as provider errors. + (thread.runtime.lastErrorClass !== "usage_limit" && !chatGptLimit) || !runId ) return null; + const resetText = displayAt + ? `${canSchedule && scheduled ? "Resumes" : "Resets"} ${new Date(displayAt).toLocaleString( + undefined, + { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }, + )}.` + : null; const snoozed = - recovery?.snooze === true && - recovery.runId === runId && - recovery.resetAt === resetAt && - resetAt !== null && - thread.snoozedUntil !== null && - Date.parse(thread.snoozedUntil) === Date.parse(resetAt); + limitRecoveryOwnsSnooze({ + runId, + resetAt, + recovery, + snoozedUntil: thread.snoozedUntil, + }) && !resetPassed; async function toggle(action: "resume" | "snooze") { if (!resetAt || !runId || !canSchedule) return; if (action === "snooze" && !snoozed && Date.parse(resetAt) <= Date.now()) { @@ -68,34 +97,125 @@ export function UsageLimitRecoveryCard({ setError(cause instanceof Error ? cause.message : "Could not change limit recovery."); } } + const action = workspaceLimit + ? { + label: "Copy request for admin", + role: "button" as const, + run: () => { + setError(null); + setCopied(false); + setActionPending(true); + void Clipboard.setStringAsync(CHATGPT_WORKSPACE_ADMIN_REQUEST) + .then( + () => setCopied(true), + () => setError("Could not copy the request. Try again."), + ) + .finally(() => setActionPending(false)); + }, + } + : { + label: "Manage usage", + role: "link" as const, + run: () => { + setError(null); + setActionPending(true); + void Linking.openURL(CHATGPT_USAGE_URL) + .catch(() => setError("Could not open ChatGPT. Go to chatgpt.com → Settings → Usage.")) + .finally(() => setActionPending(false)); + }, + }; return ( - - - {resetAt - ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` - : "The provider did not report a reset time. Retry manually when your limit is available."} - - {canSchedule ? ( - - ' + + '
'; + 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.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 81b6992a59d3b..c1f68878e9644 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,9 +1,9 @@ +import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; import { boundAttachmentIds, buildExpiredTerminalContextToastCopy, mergeDirectPreviewAnnotation, planComposerTurn, - restorePlanFollowUpComposer, snapshotComposerSend, withMessageContext, deriveAvailableRevertTurnCounts, @@ -1077,6 +1077,7 @@ describe("planComposerTurn", () => { keepFullHistory: false, phase: "ready" as const, dispatchMode: "auto" as const, + freshContext: false, }; it("prepares a worktree from the selected branch on a new thread", () => { @@ -1126,61 +1127,19 @@ describe("planComposerTurn", () => { shouldQueueBehindActiveRun: false, baseBranchForWorktree: null, }); - }); -}); - -describe("restorePlanFollowUpComposer", () => { - // The draft is the failed follow-up's thread; the refs and cursor are whatever thread is on - // screen. - it.each([ - { - name: "puts the follow-up back while its thread is open", - before: { draft: "", refs: "", cursor: null }, - routeIsCurrent: true, - after: { draft: "Tighten step 2", refs: "Tighten step 2", cursor: "Tighten step 2" }, - }, - { - name: "keeps a draft typed while the follow-up was in flight", - before: { draft: "Next idea", refs: "Next idea", cursor: null }, - routeIsCurrent: true, - after: { draft: "Next idea", refs: "Next idea", cursor: null }, - }, - { - name: "leaves another thread's composer alone after navigating away", - before: { draft: "", refs: "Other thread", cursor: null }, - routeIsCurrent: false, - after: { draft: "Tighten step 2", refs: "Other thread", cursor: null }, - }, - ])("$name", ({ before, routeIsCurrent, after }) => { - const composer: { draft: string; refs: string; cursor: string | null } = { ...before }; - restorePlanFollowUpComposer({ - snapshot: { - prompt: "Tighten step 2", - environmentContexts: [], - terminalContexts: [], - reviewComments: [], - previewAnnotations: [], - threadContexts: [], - }, - draftHasUserContent: composer.draft.trim().length > 0, - routeIsCurrent, - writeComposerRefs: (snapshot) => { - composer.refs = snapshot.prompt; - }, - writePrompt: (prompt) => { - composer.draft = prompt; - }, - writeTerminalContexts: () => undefined, - writeReviewComments: () => undefined, - writePreviewAnnotations: () => undefined, - writeThreadContexts: () => undefined, - writeEnvironmentContexts: () => undefined, - resetCursor: ({ prompt }) => { - composer.cursor = prompt; - }, + // A fresh session cannot join the running turn, and a stale one is not compacted first. + expect( + planComposerTurn({ + ...running, + dispatchMode: "steer", + freshContext: true, + resumeCompactionTokens: 120_000, + }), + ).toMatchObject({ + compactBeforeSend: false, + turnDispatchMode: "queue", + shouldQueueBehindActiveRun: true, }); - - expect(composer).toEqual(after); }); }); @@ -1258,3 +1217,25 @@ describe("deriveAvailableRevertTurnCounts", () => { expect(deriveAvailableRevertTurnCounts(null).size).toBe(0); }); }); + +describe("Open in for the host environment", () => { + it("shows both local environments when the primary has a separate WSL home", () => { + const primaryEnvironmentId = EnvironmentId.make("wsl"); + const hostEnvironmentId = EnvironmentId.make("host"); + for (const [activeThreadEnvironmentId, visible] of [ + [primaryEnvironmentId, true], + [hostEnvironmentId, true], + [EnvironmentId.make("other"), false], + ] as const) { + expect( + shouldShowOpenInPicker({ + activeProjectName: "Project", + activeThreadEnvironmentId, + primaryEnvironmentId, + hostEnvironmentId, + remoteOpenMode: "local-exec", + }), + ).toBe(visible); + } + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e933abf86b2fb..d35b6ce28c433 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -15,11 +15,7 @@ import type { SetStateAction } from "react"; import { applyClaudePromptEffortPrefix, resolvePromptInjectedEffort } from "@t3tools/shared/model"; import { getProviderModelCapabilities } from "../providerModels"; import { sanitizeThreadErrorMessage } from "@t3tools/client-runtime/errors"; -import type { - EnvironmentThread, - ThreadStructure, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentThread, ThreadStructure } from "@t3tools/client-runtime/state/shell"; import { ANTIGRAVITY_DEFAULT_MODEL, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -34,7 +30,6 @@ import { type ModelSelection, type OrchestrationMessageContext, type PreviewAnnotationPayload, - type ProviderInteractionMode, ProviderDriverKind, type ProviderInstanceId, type ServerProvider, @@ -76,8 +71,7 @@ import { } from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { environmentThreadShells, environmentThreadDetails } from "../state/threads"; -import { waitForAtomValue } from "../state/waitForAtomValue"; +import { environmentThreadDetails } from "../state/threads"; import { filterTerminalContextsWithText, formatTerminalContextLabel, @@ -94,7 +88,7 @@ import { } from "../lib/composerContextRecords"; import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import type { DraftThreadEnvMode } from "../composerDraftStore"; -import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic"; +import { type ComposerSubmissionIntent } from "../composer-logic"; import type { ReviewCommentContext } from "../reviewCommentContext"; import { derivePhase, type TimelineEntry } from "../session-logic"; import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; @@ -236,16 +230,6 @@ export function resolveProactiveTurnDiffAction(input: { return totals.count >= 3 || totals.additions + totals.deletions >= 50 ? "open" : "ignore"; } -/** Mark reactivated Diff tabs to preserve review scope. Already-active tabs must not leave a marker for - * the next open. */ -export function resolveExplicitDiffOpenOnActivate(input: { - readonly surfaceKind: RightPanelSurface["kind"]; - readonly diffOpen: boolean; - readonly activeThreadRef: ScopedThreadRef; -}): ScopedThreadRef | null { - return input.surfaceKind === "diff" && !input.diffOpen ? input.activeThreadRef : null; -} - export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, @@ -539,21 +523,6 @@ export function resolveComposerProviderSelection(input: { }; } -export function resolveComposerInteractionMode(input: { - planModeEnabled: boolean; - provider: Pick | null | undefined; - interactionMode: ProviderInteractionMode; -}): { enabled: boolean; interactionMode: ProviderInteractionMode } { - const enabled = - input.planModeEnabled && - input.provider != null && - input.provider.showInteractionModeToggle !== false; - return { - enabled, - interactionMode: enabled ? input.interactionMode : "default", - }; -} - export function getAntigravitySendBlockReason( provider: | Pick @@ -819,22 +788,6 @@ export function shouldShowBranchMismatchBanner(input: { return input.composerHasContent || input.wasShownForCurrentMismatch; } -export function shouldShowPlanFollowUpPrompt(input: { - pendingUserInputCount: number; - interactionMode: ProviderInteractionMode; - latestTurnSettled: boolean; - hasActionableProposedPlan: boolean; - hasComposerAttachments: boolean; -}): boolean { - return ( - input.pendingUserInputCount === 0 && - input.interactionMode === "plan" && - input.latestTurnSettled && - input.hasActionableProposedPlan && - !input.hasComposerAttachments - ); -} - const sessionDismissedBranchMismatchKeys = new Set(); export function dismissBranchMismatchForSession(key: string): void { @@ -873,18 +826,6 @@ function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime)); } -export function threadShellHasStarted( - shell: - | Pick - | null - | undefined, -): boolean { - return Boolean( - shell && - (shell.latestRun !== null || shell.latestUserMessageAt !== null || shell.runtime !== null), - ); -} - // Imported history lacks a session until its first prompt; resolve its instance before locking the driver. export function deriveLockedProvider(input: { thread: Thread | null | undefined; @@ -939,19 +880,6 @@ export function getStartedThreadModelChangeBlockReason(input: { }; } -export async function waitForStartedServerThread( - threadRef: ScopedThreadRef, - timeoutMs = 1_000, -): Promise { - const threadAtom = environmentThreadShells.threadShellAtom(threadRef); - return waitForAtomValue({ - registry: appAtomRegistry, - atom: threadAtom, - predicate: threadHasStarted, - timeoutMs, - }); -} - export async function waitForRevertedMessage( threadRef: ScopedThreadRef, turnCount: number, @@ -1163,55 +1091,11 @@ 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 ); } -export interface PlanFollowUpComposerSnapshot { - readonly prompt: string; - readonly terminalContexts: ReadonlyArray; - readonly reviewComments: ReadonlyArray; - readonly previewAnnotations: ReadonlyArray; - readonly threadContexts: ReadonlyArray; - readonly environmentContexts: ReadonlyArray; -} - -// A draft typed while the send was in flight is newer and stays. Composer refs and the cursor -// belong to the thread on screen, so they follow only while the follow-up's route is current. -export function restorePlanFollowUpComposer(input: { - readonly snapshot: PlanFollowUpComposerSnapshot; - readonly draftHasUserContent: boolean; - readonly routeIsCurrent: boolean; - readonly writeComposerRefs: (snapshot: PlanFollowUpComposerSnapshot) => void; - readonly writePrompt: (prompt: string) => void; - readonly writeTerminalContexts: (contexts: ReadonlyArray) => void; - readonly writeReviewComments: (comments: ReadonlyArray) => void; - readonly writePreviewAnnotations: (annotations: ReadonlyArray) => void; - readonly writeThreadContexts: (records: ReadonlyArray) => void; - readonly writeEnvironmentContexts: (records: ReadonlyArray) => void; - readonly resetCursor: (options: { - cursor: number; - prompt: string; - detectTrigger: boolean; - }) => void; -}): void { - if (input.draftHasUserContent) return; - if (input.routeIsCurrent) input.writeComposerRefs(input.snapshot); - input.writePrompt(input.snapshot.prompt); - input.writeTerminalContexts(input.snapshot.terminalContexts); - input.writeReviewComments(input.snapshot.reviewComments); - input.writePreviewAnnotations(input.snapshot.previewAnnotations); - input.writeThreadContexts(input.snapshot.threadContexts); - input.writeEnvironmentContexts(input.snapshot.environmentContexts); - if (!input.routeIsCurrent) return; - input.resetCursor({ - cursor: collapseExpandedComposerCursor(input.snapshot.prompt, input.snapshot.prompt.length), - prompt: input.snapshot.prompt, - detectTrigger: true, - }); -} - /** Includes the annotation screenshot only within the attachment cap; exceeding it rejects the whole turn. */ export function mergeDirectPreviewAnnotation(input: { readonly images: ReadonlyArray; @@ -1411,6 +1295,8 @@ export function planComposerTurn(input: { readonly keepFullHistory: boolean; readonly phase: SessionPhase; readonly dispatchMode: ComposerDispatchMode; + /** The turn starts a new provider session, so it neither compacts first nor steers. */ + readonly freshContext: boolean; }) { const isFirstMessage = !input.sendQueuesBehindSetup && (!input.isServerThread || input.activeMessageCount === 0); @@ -1420,9 +1306,14 @@ export function planComposerTurn(input: { // old history. The message queues behind the /compact run, since steering into it is // rejected. const compactBeforeSend = + !input.freshContext && input.resumeCompactionTokens !== null && !input.keepFullHistory && !COMPACT_COMMAND_PATTERN.test(input.messageText); + const dispatchMode = + input.freshContext && (input.dispatchMode === "steer" || input.dispatchMode === "restart") + ? "queue" + : input.dispatchMode; return { // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. @@ -1430,11 +1321,11 @@ export function planComposerTurn(input: { baseBranchForWorktree: shouldCreateWorktree ? input.activeThreadBranch : null, compactBeforeSend, turnDispatchMode: - compactBeforeSend || input.sendQueuesBehindSetup ? ("queue" as const) : input.dispatchMode, + compactBeforeSend || input.sendQueuesBehindSetup ? ("queue" as const) : dispatchMode, shouldQueueBehindActiveRun: compactBeforeSend || input.sendQueuesBehindSetup || - (input.phase === "running" && input.dispatchMode === "queue"), + (input.phase === "running" && dispatchMode === "queue"), }; } 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 4cefe4e7c787b..0dbaa82c710c6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -13,6 +13,7 @@ import { presentThreadShellFromProjection, } from "./ChatView.logic"; import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread"; +import { usePullRequestLinking } from "../hooks/usePullRequestLinking"; import { useComposerSelection } from "./chat/useComposerSelection"; import { useProjectCloneBanner } from "./chat/useProjectCloneBanner"; import { useAttachmentPreviewHandoff } from "./chat/useAttachmentPreviewHandoff"; @@ -28,7 +29,6 @@ import { useRightPanelActions } from "./chat/useRightPanelActions"; import { type TerminalLaunchContext, useChatTerminalActions } from "./chat/useChatTerminalActions"; import { useComposerSend } from "./chat/useComposerSend"; import { useComposerResume } from "./chat/useComposerResume"; -import { useImplementPlanInNewThread } from "./chat/useProposedPlanSend"; import { ThreadFind, ThreadFindCanvas } from "./chat/ThreadFindProvider"; import { closeThreadFind, openThreadFind, useThreadFindOpen } from "./chat/threadFindStore"; import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; @@ -45,6 +45,8 @@ import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; import { feedbackBannerItem } from "./chat/ComposerFeedback"; +import { freshSessionBannerItem } from "./chat/ComposerFreshSession"; +import { armFreshSession, disarmFreshSession, useFreshSessionArmed } from "./chat/freshSessionArms"; import { useUsageLimitsPanel } from "./chat/useUsageLimitsPanel"; import { useWorktreeSetup } from "./chat/useWorktreeSetup"; import { useChatShortcuts } from "./chat/useChatShortcuts"; @@ -73,6 +75,7 @@ import { } from "@t3tools/contracts"; import { deriveProviderSubagentStatus, + deriveProviderExtensionStatuses, deriveReportedModelSelection, deriveResumableRunId, formatModelSelectionEffort, @@ -80,6 +83,7 @@ import { deriveThreadActivityRun, deriveLatestThreadRun, deriveThreadStructureRuntime, + limitRecoveryOwnsSnooze, } from "@t3tools/client-runtime/state/thread-execution"; import { resolveWorkflowOpening, @@ -87,8 +91,10 @@ import { } from "@t3tools/client-runtime/state/subagentRuntime"; import { getUserQueuedThreadRuns, + threadSteersDuringCompaction, threadSupportsProviderHandoff, } from "@t3tools/client-runtime/state/thread-workflows"; +import { compactionBlocksSteering } from "@t3tools/client-runtime/state/composer-dispatch"; import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { @@ -114,7 +120,10 @@ import { type SetStateAction, use, useEffect, useLayoutEffect, useRef, useState import { BackgroundThreadContext } from "./chat/backgroundThread"; import { WorkspaceMutationIdContext } from "./chat/ChatMarkdownMedia"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; +import { + assistantCitationFromLocation, + useSideChatCitationReveal, +} from "../lib/assistantCitationNavigation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useStore } from "zustand"; import { shallow } from "zustand/vanilla/shallow"; @@ -127,16 +136,13 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { isElectron } from "../env"; -import { useDiffPanelStore } from "../diffPanelStore"; import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; import { composerRequiresModifier } from "../composer-logic"; import { derivePhase, deriveActiveWorkStartedAt, deriveCanInterruptRunningThread, - findLatestProposedPlan, findRevertCheckpoint, - hasActionableProposedPlan, isLatestRunSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; @@ -226,16 +232,13 @@ 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, - primaryServerKeybindingsAtom, - serverEnvironment, -} from "../state/server"; +import { primaryServerKeybindingsAtom, serverEnvironment } from "../state/server"; +import { hostEnvironmentIdAtom } from "../state/primaryEnvironment"; 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 { type EnvironmentPresentation, useEnvironment, @@ -314,7 +317,7 @@ import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/Compos import { modelFallbackBannerItem } from "./chat/ComposerUsageLimits"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; -import { hasAvailableCompactionProvider } from "./chat/ContextWindowMeter.logic"; +import { hasAvailableCompactionProvider } from "./chat/compaction.logic"; import { deriveLatestContextWindowSnapshot, hasDismissedResumeCompaction, @@ -330,11 +333,9 @@ import { buildLocalDraftThread, deriveCommittedServerUserMessageIds, dismissBranchMismatchForSession, - shouldShowPlanFollowUpPrompt, shouldRenderPreviewMiniPlayer, deriveLockedProvider, resolveFileAttachmentUrl, - resolveComposerInteractionMode, resolveComposerProviderSelection, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, @@ -356,7 +357,7 @@ import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; import { ChatRightPanel } from "./chat/ChatRightPanel"; import { useDraftEnvironmentRouting } from "./chat/useDraftEnvironmentRouting"; -import { branchMismatchBannerItem } from "./chat/ThreadActivityBanners"; +import { branchMismatchBannerItem, extensionStatusBannerItem } from "./chat/ThreadActivityBanners"; import { ThreadNow } from "./chat/ThreadNow"; import type { ThreadStructure } from "@t3tools/client-runtime/state/shell"; import { useDelayedStatus } from "../hooks/useDelayedStatus"; @@ -382,6 +383,7 @@ import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplat const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_TURN_ITEMS: ThreadStructure["turnItems"] = []; +const EMPTY_RUNS: ThreadStructure["runs"] = []; const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray = []; const EMPTY_COMPOSER_BANNER_ITEMS: ReadonlyArray = []; @@ -569,20 +571,21 @@ export default function ChatView(props: ChatViewProps) { select: (location) => location.state.assistantCitationActivation ?? location.state.__TSR_key, }); const citationLocation = { href: citationHref, key: citationKey }; - const citationRequest = (() => { - const citation = assistantCitationFromLocation(citationLocation.href); - return citation && citation.environmentId === environmentId && citation.threadId === threadId - ? { citation, key: citationLocation.key ?? citationLocation.href } - : null; - })() as AssistantCitationRequest | null; + const locationCitation = assistantCitationFromLocation(citationLocation.href); + const citationActivation = citationLocation.key ?? citationLocation.href; + const citationRequest = ( + locationCitation && + locationCitation.environmentId === environmentId && + locationCitation.threadId === threadId + ? { citation: locationCitation, key: citationActivation } + : null + ) as AssistantCitationRequest | null; + useSideChatCitationReveal(embedded ? null : routeThreadRef, locationCitation, citationActivation); const citationHistory = useCitedHistory(citationRequest); const { resolvedTheme } = useTheme(); const composerRuntimeMode = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null, ); - const composerInteractionMode = useComposerDraftStore( - (store) => store.getComposerDraft(composerDraftTarget)?.interactionMode ?? null, - ); const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); @@ -982,11 +985,19 @@ export default function ChatView(props: ChatViewProps) { ); const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; const activeThreadShell = serverThread; + const limitRecoveryRun = + serverRuntime?.status === "failed" && serverRuntime.lastErrorClass === "usage_limit" + ? (activeThreadShell?.latestRun ?? null) + : null; + // The recovery banner offers Wake now for a snooze it set. + const limitRecoveryHoldsSnooze = limitRecoveryOwnsSnooze({ + runId: limitRecoveryRun?.runId, + resetAt: serverRuntime?.usageLimitResetAt, + recovery: activeThreadShell?.limitRecovery, + snoozedUntil: activeThreadShell?.snoozedUntil, + }); const timelineThreadError = - serverRuntime?.status === "failed" && - serverRuntime.lastErrorClass === "usage_limit" && - activeThreadShell?.latestRun && - visibleThreadError === serverRuntime.lastError + limitRecoveryRun !== null && visibleThreadError === serverRuntime?.lastError ? null : visibleThreadError; @@ -1020,20 +1031,6 @@ export default function ChatView(props: ChatViewProps) { selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); const diffOpen = activeRightPanelKind === "diff"; - const explicitDiffOpenRef = useRef(null); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resets the diff panel store to branch scope when the diff opens without an explicit request - useLayoutEffect(() => { - const explicitThreadRef = explicitDiffOpenRef.current; - explicitDiffOpenRef.current = null; - if ( - diffOpen && - activeThreadRef && - explicitThreadRef !== activeThreadRef && - !useDiffPanelStore.getState().consumeKeepSelectionOnOpen(activeThreadRef) - ) { - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); - } - }, [activeThreadRef, diffOpen]); const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); @@ -1181,7 +1178,6 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); - const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); const activeEnvironmentUnavailable = @@ -1219,6 +1215,13 @@ export default function ChatView(props: ChatViewProps) { canPickEnvironment: hasMultipleEnvironments, }); + const pullRequestLinking = usePullRequestLinking(environmentId); + // Best effort: the thread already exists, so a failed link is only logged. + const linkCheckoutPullRequest = useStableCallback((threadRef: ScopedThreadRef, url: string) => { + pullRequestLinking.changeLink(threadRef, url, true).catch((error: unknown) => { + console.warn("Failed to link the checked-out pull request to its thread.", error); + }); + }); const { handlePreparedPullRequestThreadStable, openPullRequestDialogStable } = usePullRequestDraftThread({ routeKind, @@ -1321,12 +1324,6 @@ export default function ChatView(props: ChatViewProps) { const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind; const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null; const activeProviderStatus = selectedProviderEntry?.snapshot ?? null; - const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({ - planModeEnabled: settings.planModeEnabled, - provider: activeProviderStatus, - interactionMode: - composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, - }); const conversationProviderStatus = providerStatuses.find((status) => status.instanceId === activeRuntime?.providerInstanceId) ?? activeProviderStatus; @@ -1383,19 +1380,6 @@ export default function ChatView(props: ChatViewProps) { const onHeldRequestIdsChange = useStableCallback((requestIds: ReadonlySet) => { setHeldApprovalRequestIds((current) => (shallow(current, requestIds) ? current : requestIds)); }); - const activeProposedPlan = (() => { - if (!latestRunSettled) { - return null; - } - return findLatestProposedPlan(serverProjection, activeLatestRun?.runId ?? null); - })(); - const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({ - pendingUserInputCount: pendingUserInputs.length, - interactionMode, - latestTurnSettled: latestRunSettled, - hasActionableProposedPlan: hasActionableProposedPlan(activeProposedPlan), - hasComposerAttachments: composerHasAttachments, - }); // Answering an approval or question on any client resumes quota spending, so include it in the key. const usageLimitsKey = activeProviderInstanceId === null || (isServerThread && activeThread === undefined) @@ -1466,9 +1450,17 @@ export default function ChatView(props: ChatViewProps) { item.runId === activeRuntime?.activeRunId && item.status === "running", ); + const compactCommandIsActive = compactRequestIsActive && !compactionSettled; const isCompacting = (isSendBusy || phase === "connecting" || phase === "running") && - (compactionRunning || (compactRequestIsActive && !compactionSettled)); + (compactionRunning || compactCommandIsActive); + const compactionBlocksSteer = compactionBlocksSteering({ + compactCommandIsActive, + compactionRunning, + steersDuringCompaction: threadSteersDuringCompaction(serverProjection), + run: serverProjection?.runs.find((run) => run.id === activeRuntime?.activeRunId), + messages: serverProjection?.messages, + }); const isWorking = phase === "running" || isSendBusy || isCompacting || runlessWorkStartedAt !== null; const activeContextWindow = (() => { @@ -1635,7 +1627,7 @@ export default function ChatView(props: ChatViewProps) { const turnDiffSummaries = useThreadCheckpointSummaries(routeThreadDetailRef); const availableRevertTurnCounts = deriveAvailableRevertTurnCounts(serverProjection); // A paged history window cannot identify the first loaded run as the thread start. - const threadStartCheckpointReady = + const threadStartCheckpointAvailable = !serverThreadHistory.hasMoreHistory && findRevertCheckpoint(serverProjection?.checkpoints ?? [], 0) !== undefined; @@ -1651,14 +1643,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, @@ -1670,17 +1665,21 @@ export default function ChatView(props: ChatViewProps) { const scriptKeybindings = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId); + const openInEnvironmentId = activeThread?.environmentId ?? environmentId; + const availableEditors = + useAtomValue(serverEnvironment.configValueAtom(openInEnvironmentId))?.availableEditors ?? []; + const hostEnvironmentId = useAtomValue(hostEnvironmentIdAtom); + const remoteOpenState = useRemoteOpenState(openInEnvironmentId); const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName: activeProject?.title, - activeThreadEnvironmentId: activeThread?.environmentId ?? environmentId, - primaryEnvironmentId, + activeThreadEnvironmentId: openInEnvironmentId, + primaryEnvironmentId: primaryEnvironment?.environmentId ?? null, + hostEnvironmentId, remoteOpenMode: remoteOpenState.mode, }); useOpenFavoriteEditorShortcut({ enabled: showOpenInPicker && !backgroundThread, - environmentId: activeThread?.environmentId ?? environmentId, + environmentId: openInEnvironmentId, keybindings, availableEditors, openInCwd: gitCwd, @@ -1902,7 +1901,7 @@ export default function ChatView(props: ChatViewProps) { threadId: activeThreadId, message: { messageId: newMessageId(), role: "user", text, attachments: [] }, runtimeMode, - interactionMode, + interactionMode: DEFAULT_INTERACTION_MODE, dispatchMode: "queue", }, }); @@ -2417,7 +2416,6 @@ export default function ChatView(props: ChatViewProps) { pullRequestsSurfaceAvailable, gitCwd, activeProjectCwd, - explicitDiffOpenRef, onDiffPanelOpen, closeRightPanelSurface, }); @@ -2451,6 +2449,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, activeThreadShell, activeLatestRun, + limitRecoveryHoldsSnooze, activeProjectCwd, serverConfig, runtimeMode, @@ -2504,18 +2503,8 @@ export default function ChatView(props: ChatViewProps) { activeEnvironmentUnavailable || feedbackUploading || pendingApprovals.length > 0 || - pendingUserInputs.length > 0 || - showPlanFollowUpPrompt; + pendingUserInputs.length > 0; const compactDisabled = compactThreadUnavailable; - const compactDisabledReason = compactDisabled - ? !canOperateThread - ? "This connection cannot change threads." - : !activeProject - ? "Choose a project before compacting" - : !manualCompactionProviderAvailable - ? "Compaction is unavailable for this provider" - : "Compacting is unavailable right now" - : null; const resumeCompactionTokens = activeContextWindow && resolveIdleCompaction( @@ -2549,8 +2538,15 @@ export default function ChatView(props: ChatViewProps) { return next; }); }; + const freshSessionAvailable = + activeMessageCount > 0 && serverConfig?.environment.capabilities.freshContext === true; + // While a question or approval owns the composer, its text answers that, not the agent. + const freshSessionCommandAvailable = + freshSessionAvailable && activePendingApproval === null && pendingUserInputs.length === 0; + const freshSessionArmed = useFreshSessionArmed(routeThreadKey); const toggleKeepFullHistory = () => setKeepFullHistory(routeThreadKey, !keepFullHistory); const toggleKeepFullHistoryStable = useStableCallback(toggleKeepFullHistory); + const armFreshSessionStable = useStableCallback(() => armFreshSession(routeThreadKey)); const handleRestoreThreadBranch = () => { if (!canWriteSourceControl) return; if (gitStatusQuery.data?.hasWorkingTreeChanges) { @@ -2572,14 +2568,14 @@ export default function ChatView(props: ChatViewProps) { return item ? [item] : []; }); const limitRecoveryBanner = - serverRuntime?.status === "failed" && - serverRuntime.lastErrorClass === "usage_limit" && - activeThreadShell?.latestRun + limitRecoveryRun !== null && activeThreadShell !== null ? usageLimitRecoveryBannerItem({ - runId: activeThreadShell.latestRun.runId, - errorCode: serverRuntime.lastErrorCode ?? null, - resetAt: serverRuntime.usageLimitResetAt ?? null, - stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt, + runId: limitRecoveryRun.runId, + errorCode: serverRuntime?.lastErrorCode ?? null, + resetAt: serverRuntime?.usageLimitResetAt ?? null, + stoppedAt: limitRecoveryRun.completedAt ?? activeThreadShell.updatedAt, + settled: activeThreadShell.settledOverride === "settled", + requestPending: pendingApprovals.length > 0 || pendingUserInputs.length > 0, recovery: activeThreadShell.limitRecovery ?? null, snoozedUntil: activeThreadShell.snoozedUntil, timestampFormat, @@ -2639,15 +2635,26 @@ export default function ChatView(props: ChatViewProps) { ] : []; const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; + const extensionStatusBanner = extensionStatusBannerItem( + deriveProviderExtensionStatuses(serverProjection), + ); + const extensionStatusItems = extensionStatusBanner === null ? [] : [extensionStatusBanner]; const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; + // An approval's feedback goes to that approval; the armed session waits for the next message. + const freshSessionItems = + freshSessionAvailable && freshSessionArmed && activePendingApproval === null + ? [freshSessionBannerItem(() => disarmFreshSession(routeThreadKey))] + : []; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, ...fallbackItems, ...limitRecoveryItems, + ...extensionStatusItems, ...usageLimitsItems, ...projectCloneItems, + ...freshSessionItems, ...systemComposerBannerItems, ]; } @@ -2655,8 +2662,10 @@ export default function ChatView(props: ChatViewProps) { ...feedbackBannerItems, ...fallbackItems, ...limitRecoveryItems, + ...extensionStatusItems, ...usageLimitsItems, ...projectCloneItems, + ...freshSessionItems, ...systemComposerBannerItems, branchMismatchBannerItem({ mismatchKey: activeBranchMismatchKey, @@ -2804,6 +2813,10 @@ export default function ChatView(props: ChatViewProps) { serverConfig?.environment.capabilities.sideChats === true; const sideChat = useSideChat({ threadRef: sideChatsOffered && isServerThread ? activeThreadRef : null, + readSettings: () => ({ + runtimeMode, + modelSelection: composerRef.current?.getSendContext().selectedModelSelection, + }), onError: (message) => { if (activeThread) setThreadError(activeThread.id, message); }, @@ -2862,14 +2875,11 @@ export default function ChatView(props: ChatViewProps) { const { onSubagentModelSelectStable, getModelDisabledReason, - handleInteractionModeChange, - handleInteractionModeChangeStable, handleRuntimeModeChangeStable, onEnvModeChangeStable, onProviderModelSelectStable, onStartFromOriginChange, openProviderSetupStable, - toggleInteractionModeStable, } = useComposerSelection({ environmentId, composerDraftTarget, @@ -2883,8 +2893,6 @@ export default function ChatView(props: ChatViewProps) { supportsProviderSwitchingViaHandoff, multipleModelSelections, runtimeMode, - interactionMode, - interactionModeEnabled, isLocalDraftThread, canOverrideServerThreadEnvMode, setPendingServerThreadEnvModeEntry, @@ -2903,7 +2911,6 @@ export default function ChatView(props: ChatViewProps) { threadDetailLoading, activeEnvironmentUnavailable, runtimeMode, - interactionMode, localCheckoutBranchMismatch, composerRef, persistThreadSettingsForNextTurn, @@ -2911,21 +2918,6 @@ export default function ChatView(props: ChatViewProps) { scrollToEnd, setThreadError, }); - const onImplementPlanInNewThread = useImplementPlanInNewThread({ - environmentId, - routeThreadKey, - activeThread, - activeThreadBranch, - activeProposedPlan, - isServerThread, - isSendBusy, - composerRef, - beginLocalDispatch, - resetLocalDispatch, - activeProject, - activeEnvironmentUnavailable, - defaultRuntimeMode, - }); const { onSend } = useComposerSend({ environmentId, routeThreadKey, @@ -2937,7 +2929,6 @@ export default function ChatView(props: ChatViewProps) { activeThreadBranch, activeProject, activeProjectDefaultModelSelection, - activeProposedPlan, activeMessageCount, isServerThread, isLocalDraftThread, @@ -2966,8 +2957,8 @@ export default function ChatView(props: ChatViewProps) { panelAnimationsActive, panelAnimationDurationMs, keepFullHistory, + freshSessionAvailable: freshSessionCommandAvailable, resumeCompactionTokens, - showPlanFollowUpPrompt, usageLimitsKey, usageLimitsOffered, openUsageLimits, @@ -2996,13 +2987,12 @@ export default function ChatView(props: ChatViewProps) { setKeepFullHistory, anchorNewTurn, acknowledgeActiveThreadWoke, - handleInteractionModeChange, onSideChat: sideChat.available ? sendSideChatStable : undefined, sideChatDisabledReason, handleNewThreadInActiveProject, persistThreadSettingsForNextTurn, + linkCheckoutPullRequest, }); - const onImplementPlanInNewThreadStable = useStableCallback(onImplementPlanInNewThread); const onWorktreeSetupWorkLocally = () => { if (!worktreeSetup || worktreeSetup.phase !== "running" || !draftId) { @@ -3273,6 +3263,7 @@ export default function ChatView(props: ChatViewProps) { }} issueProject={activeProject} files={{ + projectId: activeProject?.id, projectName: activeProject?.title ?? null, workspaceRoot: activeWorkspaceRoot, availableEditors, @@ -3500,7 +3491,7 @@ export default function ChatView(props: ChatViewProps) { parentThreadLink={parentThreadLink} workflowOpening={workflowOpening} supportsConversationRollback={supportsConversationRollback} - threadStartCheckpointReady={threadStartCheckpointReady} + threadStartCheckpointAvailable={threadStartCheckpointAvailable} availableRevertTurnCounts={availableRevertTurnCounts} onRevertToTurnCount={onRevertTimelineTurnStable} onUseArtifactTemplate={insertArtifactTemplateStable} @@ -3550,7 +3541,7 @@ export default function ChatView(props: ChatViewProps) { data-chat-composer-overlay="true" className={ isDraftHeroState - ? "pointer-events-none absolute inset-0 z-20 flex items-center" + ? "pointer-events-none absolute inset-0 z-20 flex items-center [@media(max-height:34rem)]:items-end" : "pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2" } > @@ -3587,280 +3578,279 @@ export default function ChatView(props: ChatViewProps) { } > - - -
- {showProviderSubagentBar ? ( - - ) : null - } - /> - ) : null} - {!composerMounted ? null : ( - - )} -
-
-
-
+ + - {showComposerContextStrip && ( -
- + {showProviderSubagentBar ? ( + + ) : null + } + /> + ) : null} + {!composerMounted ? null : ( + - ), - })} + environmentUnavailable={activeEnvironmentUnavailableState} + activePendingApproval={activePendingApproval} + pendingApprovals={pendingApprovals} + onHeldRequestIdsChange={onHeldRequestIdsChange} + pendingUserInputs={pendingUserInputs} + activePendingProgress={activePendingProgress} + activePendingResolvedAnswers={activePendingResolvedAnswers} + activePendingIsResponding={activePendingIsResponding} + activePendingDraftAnswers={activePendingDraftAnswers} + activePendingQuestionIndex={activePendingQuestionIndex} + respondingRequestIds={respondingRequestIds} + runtimeMode={runtimeMode} + defaultRuntimeMode={defaultRuntimeMode} + lockedProvider={modelPickerLockedProvider} + providerStatuses={providerStatuses as ServerProvider[]} + providerCatalogKnown={serverConfig !== null} + activeProjectDefaultModelSelection={ + activeProjectDefaultModelSelection + } + activeThreadModelSelection={activeThread?.modelSelection} + activeThreadSubagentModelSelection={ + activeThread?.subagentModelSelection + } + activeProjectId={activeProject?.id ?? null} + projectName={activeProject?.title} + compactThreadUnavailable={compactThreadUnavailable} + compactDisabled={compactDisabled} + resolvedTheme={resolvedTheme} + settings={settings} + keybindings={keybindings} + terminalOpen={Boolean(terminalUiState.terminalOpen)} + gitCwd={gitCwd} + pullRequestProjectId={ + supportsPullRequests ? (activeProject?.id ?? null) : null + } + pullRequestRepository={ + supportsPullRequests ? activeProjectRepository : null + } + threadPullRequests={ + (activeThreadShell ?? activeThread)?.pullRequests + } + onComposerOverlayHeightChange={publishComposerOverlayHeightStable} + permissionsShownInStrip={ + showComposerContextStrip && !isServerThread + } + promptRef={promptRef} + composerImagesRef={composerImagesRef} + composerFilesRef={composerFilesRef} + composerTerminalContextsRef={composerTerminalContextsRef} + onPageScrollKeyDown={onComposerPageScrollKeyDownStable} + onPageScrollKeyUp={onComposerPageScrollKeyUpStable} + onPageScrollRelease={onComposerPageScrollReleaseStable} + onCompactContext={onComposerCompactContextStable} + onSend={onComposerSendStable} + onResume={onComposerResumeStable} + onInterrupt={onInterruptStable} + onSelectActivePendingUserInputOption={ + onSelectActivePendingUserInputOptionStable + } + onAdvanceActivePendingUserInput={ + onAdvanceActivePendingUserInputStable + } + onDismissActivePendingUserInput={onDismissUserInputStable} + onPreviousActivePendingUserInputQuestion={ + onPreviousActivePendingUserInputQuestionStable + } + onChangeActivePendingUserInputCustomAnswer={ + onChangeActivePendingUserInputCustomAnswer + } + onProviderModelSelect={onProviderModelSelectStable} + onOpenProviderSetup={openProviderSetupStable} + onSubagentModelSelect={ + isServerThread ? onSubagentModelSelectStable : null + } + getModelDisabledReason={getModelDisabledReason} + handleRuntimeModeChange={handleRuntimeModeChangeStable} + focusComposer={focusComposerStable} + scheduleComposerFocus={scheduleComposerFocusStable} + setThreadError={setThreadError} + onExpandImage={onExpandTimelineImageStable} + onFileOpen={openFileAttachmentStable} + editingQueuedAttachments={queuedRunEdit.editingAttachments} + onRemoveEditingQueuedAttachment={ + queuedRunEdit.removeEditingAttachment + } /> -
- )} + )} +
+ +
+
+ {showComposerContextStrip && ( +
+ + ), + })} + /> +
+ )} +
-
+
{isDraftHeroState && composerBannerItems.length > 0 ? ( // Out of flow, like the headline, so a notice never moves the centered composer. -
+ // Short windows pin the composer to the bottom, where an out-of-flow notice + // would fall off-screen, so there it takes space and lifts the composer. +
) : null} - +
) : null} - + {embedded ? null : } diff --git a/apps/web/src/components/CommandBlock.tsx b/apps/web/src/components/CommandBlock.tsx index 2f4454932278f..69f68529c839d 100644 --- a/apps/web/src/components/CommandBlock.tsx +++ b/apps/web/src/components/CommandBlock.tsx @@ -1,6 +1,5 @@ -import { Check, Copy } from "lucide"; +import { CheckIcon, CopyIcon } from "lucide-react"; -import { MorphIcon } from "~/components/MorphIcon"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; @@ -37,7 +36,7 @@ export function CommandBlock({ aria-label="Copy command" onClick={() => copyToClipboard(command, undefined)} > - + {isCopied ? : }
); diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 9faeab83c6ffc..c7106f0cff289 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -1,8 +1,8 @@ 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 { act, render } from "../../test/render"; import { openCommandPalette } from "../commandPaletteBus"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { makeThreadFixture } from "../test-fixtures"; @@ -26,6 +26,56 @@ function setup(state: { projects?: ReadonlyArray; threads?: ReadonlyArr } describe("CommandPalette open intents", () => { + it("drags a searched thread into the current composer without navigating", async () => { + const environmentId = EnvironmentId.make("remote"); + const threadId = ThreadId.make("dragged-thread"); + setup({ + threads: [makeThreadFixture({ environmentId, id: threadId, title: "Drag this thread" })], + }); + const onDrop = vi.fn(); + await using screen = await render( + +
{ + node?.addEventListener("t3-thread-context-drop", onDrop); + }} + style={{ position: "fixed", bottom: 0, width: 300, height: 100 }} + > + Composer +
+
, + ); + const pathname = screen.router?.state.location.pathname; + openCommandPalette({ query: "Drag this thread" }); + const row = page.getByRole("option", { name: /Drag this thread/ }); + await expect.element(row).toBeVisible(); + const bounds = row.element().getBoundingClientRect(); + const pointer = (type: string, x: number, y: number) => + new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 1, + isPrimary: true, + button: 0, + buttons: type === "pointerup" ? 0 : 1, + clientX: x, + clientY: y, + }); + await act(() => { + row.element().dispatchEvent(pointer("pointerdown", bounds.x + 20, bounds.y + 10)); + document.dispatchEvent(pointer("pointermove", bounds.x + 30, bounds.y + 20)); + }); + expect(useCommandPaletteStore.getState().open).toBe(false); + await act(() => { + document.dispatchEvent(pointer("pointermove", 50, window.innerHeight - 30)); + document.dispatchEvent(pointer("pointerup", 50, window.innerHeight - 30)); + }); + expect(onDrop).toHaveBeenCalledOnce(); + expect((onDrop.mock.calls[0]![0] as CustomEvent).detail).toEqual([{ environmentId, threadId }]); + expect(screen.router?.state.location.pathname).toBe(pathname); + }); + it("opens New thread in... with focus in the search input, not the Back button", async () => { setup(); await using _screen = await render({null}); @@ -35,6 +85,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.composition.browser.test.tsx b/apps/web/src/components/CommandPalette.composition.browser.test.tsx index 2eb5841180825..5a2682df6f656 100644 --- a/apps/web/src/components/CommandPalette.composition.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.composition.browser.test.tsx @@ -2,11 +2,17 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; import { act, render } from "../../test/render"; import { CommandPalette } from "./CommandPalette"; import { openCommandPalette } from "../commandPaletteBus"; import { primaryServerKeybindingsAtom } from "../state/server"; +const scopes = vi.hoisted(() => ({ denied: undefined as string | undefined })); +const setup = (deniedScope?: string) => { + scopes.denied = deniedScope; +}; + vi.mock("@effect/atom-react", async (original) => ({ ...(await original()), useAtomValue: (atom: unknown) => @@ -39,7 +45,8 @@ vi.mock("../hooks/useHandleNewThread", () => ({ vi.mock("../hooks/useOpenPanelPullRequestUrl", () => ({ useOpenPanelPullRequestUrl: () => null })); vi.mock("../connection/useDesktopLocalBootstraps", () => ({ useDesktopLocalBootstraps: () => [] })); vi.mock("../state/use-atom-command", () => ({ - useAtomCommand: () => async () => ({ _tag: "Success", value: undefined }), + // The add-project destination step starts a clone and follows its project id. + useAtomCommand: () => async () => ({ _tag: "Success", value: { projectId: "project-cloned" } }), })); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => async () => ({ _tag: "Success", value: undefined }), @@ -56,6 +63,7 @@ vi.mock("../state/environments", async (original) => ({ serverConfig: { environment: { platform: { os: "linux" }, capabilities: { projectCloneTracking: true } }, settings: { addProjectBaseDirectory: "/workspace/" }, + newProjectsRoot: "/workspace/", }, }, ], @@ -84,7 +92,7 @@ vi.mock("../state/queries", async (original) => ({ })); vi.mock("../state/session", async (original) => ({ ...(await original()), - useEnvironmentScope: () => true, + useEnvironmentScope: (_environmentId: unknown, scope: string) => scope !== scopes.denied, readEnvironmentScope: () => true, })); vi.mock("../state/filesystem", async (original) => ({ @@ -105,6 +113,7 @@ describe.each(["repository", "folder", "destination"] as const)("add-project %s" { isComposing: true, keyCode: 13 }, { isComposing: false, keyCode: 229 }, ])("keeps Enter in the input during composition: %j", async (composition) => { + setup(); await using _screen = await render({null}); await act(() => openCommandPalette({ open: "add-project" })); await page.getByText(step === "folder" ? "Local folder" : "Git URL", { exact: true }).click(); @@ -133,3 +142,69 @@ describe.each(["repository", "folder", "destination"] as const)("add-project %s" } }); }); + +it.each([ + { step: "folder", source: "Local folder", keys: "", description: /^(?!.*(Ctrl|⌘)).*Add.*Enter/ }, + { + step: "highlighted folder", + source: "Local folder", + keys: "{ArrowDown}", + description: /Add.*(Ctrl|⌘) Enter/, + }, + { step: "repository", source: "Git URL", keys: "", description: /Continue.*Enter/ }, + { step: "new project", source: "New project", keys: "", description: /Create.*Enter/ }, + { + step: "permission-blocked folder", + source: "Local folder", + keys: "", + deniedScope: AuthOrchestrationOperateScope, + description: "This connection cannot add projects.", + }, + { + step: "permission-blocked destination", + source: "Git URL", + keys: "https://example.com/team/project.git{Enter}", + deniedScope: AuthSourceControlWriteScope, + description: "This connection needs permission to write source control and add projects.", + }, +])( + "tells screen readers how to submit the $step step", + async ({ source, keys, deniedScope, description }) => { + setup(deniedScope); + await using _screen = await render({null}); + await act(() => openCommandPalette({ open: "add-project" })); + await page.getByText(source, { exact: true }).click(); + if (keys) await userEvent.keyboard(keys); + await expect.element(page.getByRole("combobox")).toHaveAccessibleDescription(description); + }, +); + +it("keeps the caret in place when an edit briefly makes the folder path not a path", async () => { + setup(); + await using _screen = await render({null}); + await act(() => openCommandPalette({ open: "add-project" })); + await page.getByText("Local folder", { exact: true }).click(); + const input = () => page.getByRole("combobox"); + await input().fill("/workspace/project"); + await userEvent.keyboard("{Home}{ArrowRight}{Backspace}"); + await expect.element(input()).toHaveValue("workspace/project"); + await userEvent.keyboard("/"); + await expect.element(input()).toHaveValue("/workspace/project"); + await userEvent.keyboard("w"); + await expect.element(input()).toHaveValue("/wworkspace/project"); +}); + +it.each([ + { flow: "Git URL", value: "https://github.com/pingdotgg/t3code.git", submit: "Continue (Enter)" }, + { flow: "New project", value: "demo-tab-order", submit: /^Create \(/ }, +])("moves focus from the $flow input to its enabled submit button on Tab", async (step) => { + await using _screen = await render({null}); + await act(() => openCommandPalette({ open: "add-project" })); + await page.getByText(step.flow, { exact: true }).click(); + const input = page.getByRole("combobox"); + await input.fill(step.value); + const submit = page.getByRole("button", { name: step.submit }); + await expect.element(submit).toBeEnabled(); + await userEvent.keyboard("{Tab}"); + await expect.element(submit).toHaveFocus(); +}); diff --git a/apps/web/src/components/CommandPalette.drag.ts b/apps/web/src/components/CommandPalette.drag.ts new file mode 100644 index 0000000000000..3dec40e02b0f1 --- /dev/null +++ b/apps/web/src/components/CommandPalette.drag.ts @@ -0,0 +1,36 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { flushSync } from "react-dom"; +import { dispatchCommandPalette } from "../commandPaletteStore"; +import { SidebarPointerSensor } from "./Sidebar.pointer"; +import { + dropThreadContext, + endThreadContextDrag, + moveThreadContextDrag, +} from "./chat/threadContextDrag"; + +export function startPaletteThreadDrag( + event: PointerEvent, + thread: ScopedThreadRef, + title: string, +) { + if (!event.isPrimary || event.button !== 0 || event.pointerType === "touch") return; + // The document-owned sensor must survive closing and unmounting the palette on activation. + return new SidebarPointerSensor({ + event, + active: `${thread.environmentId}:${thread.threadId}`, + options: { + distance: 6, + onAttach: () => {}, + onFinish: endThreadContextDrag, + onDrop: (point) => dropThreadContext(point, [thread]), + }, + onStart: () => { + flushSync(() => dispatchCommandPalette({ _tag: "SetOpen", open: false })); + }, + onMove: (point) => moveThreadContextDrag(point, { title, count: 1 }), + onPending: () => {}, + onAbort: () => {}, + onCancel: () => {}, + onEnd: () => {}, + }); +} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index c034c7f4131c1..cc23a79ddddad 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -948,7 +948,7 @@ describe("newThreadNoProjectIndex", () => { }); it("goes after the pinned projects and the first unpinned one", () => { - expect(newThreadNoProjectIndex([entry(true), entry(true), entry(false, true), entry()])).toBe( + expect(newThreadNoProjectIndex([entry(false, true), entry(true), entry(true), entry()])).toBe( 3, ); expect(newThreadNoProjectIndex([entry(false, true), entry(), entry(false, false, true)])).toBe( diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index cc8fd9091ded8..db7ea4895b7ce 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -19,6 +19,7 @@ import { type AddProjectRemoteSourceReadiness, } from "@t3tools/client-runtime/operations/projects"; import { + type ScopedThreadRef, type EnvironmentId, type ProjectId, type EnvironmentMachineKind, @@ -48,6 +49,7 @@ export function buildLinkedThreadActionItems( return input.threads.map((thread) => ({ kind: "action", value: `thread:${input.environmentId}:${thread.id}`, + threadRef: { environmentId: input.environmentId, threadId: thread.id }, title: thread.title || "Untitled thread", description: thread.archivedAt === null ? "Linked thread" : "Archived thread", searchTerms: [input.query, thread.title], @@ -194,6 +196,7 @@ export interface CommandPaletteItem { readonly timestamp?: string; /** Thread rows rank by this, with use history, instead of by `searchTerms`. */ readonly threadSearch?: ThreadSearchSubject; + readonly threadRef?: ScopedThreadRef; /** Shown in place of the description when the match is not in the title. */ readonly matchHint?: SearchMatchHint; readonly icon: ReactNode; @@ -466,6 +469,7 @@ export function buildThreadActionItems + { @@ -429,6 +442,8 @@ function OpenCommandPaletteDialog(props: { const resultListRef = useRef(null); // A pushed view drops the typed highlight until the user moves it again. const highlightClearedRef = useRef(viewStack.length > 0); + // Entering or leaving browse mode remounts the input; carry the caret across it. + const remountSelectionRef = useRef(null); function clearTypedHighlight(): void { highlightClearedRef.current = true; setHighlightedItemValue(null); @@ -668,6 +683,14 @@ function OpenCommandPaletteDialog(props: { const activeThreadKey = activeThread ? scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)) : null; + const previewPanelThreadRef = activeThread + ? scopeThreadRef(activeThread.environmentId, activeThread.id) + : activeDraftThread + ? scopeThreadRef(activeDraftThread.environmentId, activeDraftThread.threadId) + : null; + const activeThreadPreviewOpen = useRightPanelStore( + (state) => selectActiveRightPanel(state.byThreadKey, previewPanelThreadRef) === "preview", + ); const supportsThreadFind = environments.find((environment) => environment.environmentId === activeThread?.environmentId) ?.serverConfig?.threadFind === true; @@ -823,8 +846,15 @@ function OpenCommandPaletteDialog(props: { (() => { const isScratch = (project: CommandPaletteProject) => isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); + const currentEntry = + currentProjectId === null + ? undefined + : projectPickerEntries.find((entry) => entry.isPreferred); const projectItems = buildNewThreadInProjectItems({ projects: pickerProjects.filter((project) => !isScratch(project)), + currentProjectKey: currentEntry + ? `${currentEntry.targetProject.environmentId}:${currentEntry.targetProject.id}` + : null, projectGroupByTargetKey, locationByEnvironmentId: projectEnvironmentLocationById, pin: projectPin, @@ -923,10 +953,25 @@ function OpenCommandPaletteDialog(props: { setHighlightedItemValue(null); } - function handleQueryChange(nextQuery: string): void { + function handleQueryChange(nextQuery: string, source: EventTarget | null): void { browseNavigation.invalidate(); clearTypedHighlight(); - setQuery(canBrowsePaths ? resolvePastedPath(query, nextQuery) : nextQuery); + const resolvedQuery = canBrowsePaths ? resolvePastedPath(query, nextQuery) : nextQuery; + // A rewritten paste has no matching offsets, so its caret lands at the end. + if ( + source instanceof HTMLInputElement && + resolvedQuery === nextQuery && + (canBrowsePaths && isFilesystemBrowseQuery(resolvedQuery, browseEnvironmentPlatform)) !== + isBrowsing + ) { + remountSelectionRef.current = { + value: resolvedQuery, + start: source.selectionStart, + end: source.selectionEnd, + direction: source.selectionDirection, + }; + } + setQuery(resolvedQuery); if (nextQuery === "" && currentView?.initialQuery) { popView(); } @@ -1313,6 +1358,20 @@ function OpenCommandPaletteDialog(props: { }); } + if (activeThreadPreviewOpen && previewBridge) { + actionItems.push({ + kind: "action", + value: "find-in-browser-page", + title: "Find on page in browser", + searchTerms: ["find", "search", "browser", "preview", "page"], + icon: , + shortcutCommand: "preview.find", + run: async () => { + dispatchPreviewAction("find"); + }, + }); + } + const promptLinkTarget = paletteRouteTarget ? paletteRouteTarget.kind === "server" ? paletteRouteTarget.threadRef @@ -1577,6 +1636,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", @@ -1948,7 +2035,7 @@ function OpenCommandPaletteDialog(props: { function getDefaultCloneDestination( environmentId: EnvironmentId, remoteUrl: string, - repository: string, + repository?: string, ): string { const environment = environments.find((candidate) => candidate.environmentId === environmentId); return getCloneDestinationPath( @@ -2003,7 +2090,6 @@ function OpenCommandPaletteDialog(props: { const destinationPath = getDefaultCloneDestination( addProjectCloneFlow.environmentId, remoteUrl, - rawRepository, ); setAddProjectCloneFlow({ step: "confirm", @@ -2109,12 +2195,11 @@ function OpenCommandPaletteDialog(props: { return; } - const projectId = newProjectId(); setIsRemoteProjectCloning(true); const startResult = await startProjectClone({ environmentId: addProjectCloneFlow.environmentId, input: { - projectId, + projectId: newProjectId(), title: inferProjectTitleFromPath(destinationPath), // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309) createdAt: new Date().toISOString(), @@ -2140,7 +2225,10 @@ function OpenCommandPaletteDialog(props: { addProjectCloneFlow.environmentId, addProjectCloneFlow.remoteUrl, ); - const projectRef = scopeProjectRef(addProjectCloneFlow.environmentId, projectId); + const projectRef = scopeProjectRef( + addProjectCloneFlow.environmentId, + startResult.value.projectId, + ); await waitForProject(projectRef, 3_000).catch(() => null); const navigationResult = await settlePromise(() => handleNewThread(projectRef)); if (navigationResult._tag === "Failure") { @@ -2592,6 +2680,7 @@ function OpenCommandPaletteDialog(props: { scrollCommandPaletteRowIntoView( resultListRef.current, resultRows.rowIndexByItemIndex[0] ?? 0, + true, ); return; } @@ -2718,6 +2807,26 @@ function OpenCommandPaletteDialog(props: { ); return; } + if (addProjectCloneFlow?.step === "confirm") { + if (selection.environmentId !== addProjectCloneFlow.environmentId) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: "Choose a folder in the environment the repository is cloned into.", + }), + ); + return; + } + await submitAddProjectCloneFlow( + getCloneDestinationForPickedFolder({ + pickedPath: selection.linuxPath, + cloneDirectoryName: pinnedCloneDirectoryName, + caseSensitive: true, + }), + ); + return; + } await handleAddProjectForEnvironment({ environmentId: selection.environmentId, rawCwd: selection.linuxPath, @@ -2726,12 +2835,30 @@ function OpenCommandPaletteDialog(props: { }); return; } + if (addProjectCloneFlow?.step === "confirm") { + await submitAddProjectCloneFlow( + getCloneDestinationForPickedFolder({ + pickedPath, + cloneDirectoryName: pinnedCloneDirectoryName, + caseSensitive: !isWindowsPlatform(browseEnvironmentPlatform), + }), + ); + return; + } await handleAddProject(pickedPath); }; + const submitButtonId = useId(); + const browseSubmitLabel = + isCloneDestinationStep && !canCloneProject + ? "This connection needs permission to write source control and add projects." + : canCreateProject + ? `${submitActionLabel} (${addShortcutLabel})` + : "This connection cannot add projects."; const inputAccessory = newProjectFlow !== null ? ( ) : addProjectCloneFlow?.step === "repository" ? ( ) : isBrowsing ? ( handleQueryChange(nextQuery, event.target)} + pendingSelectionRef={remountSelectionRef} showBackHint={isSubmenu} value={query} > diff --git a/apps/web/src/components/CommandPaletteAddProject.tsx b/apps/web/src/components/CommandPaletteAddProject.tsx index 808536403a4bb..ce3797848e317 100644 --- a/apps/web/src/components/CommandPaletteAddProject.tsx +++ b/apps/web/src/components/CommandPaletteAddProject.tsx @@ -34,6 +34,7 @@ export function sourceControlHostIcon( /** The input's trailing submit button for the add-project, clone, and new-project flows. */ export function CommandPaletteSubmitButton(props: { + readonly id: string; readonly label: ReactNode; readonly shortcutLabel: string; readonly ariaLabel: string; @@ -46,9 +47,9 @@ export function CommandPaletteSubmitButton(props: { | undefined, +) { + const selection = pendingSelectionRef?.current; + if (!pendingSelectionRef || !selection) return; + pendingSelectionRef.current = null; + if (input.value !== selection.value) return; + input.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined); +} + type CommandPaletteContentProps = Omit, "children"> & { readonly children: ReactNode; readonly escapeLabel?: ReactNode; @@ -28,6 +45,7 @@ type CommandPaletteContentProps = Omit, "children readonly footerTrailing?: ReactNode; readonly inputAccessory?: ReactNode; readonly inputProps: ComponentProps; + readonly pendingSelectionRef?: RefObject; readonly panelSize?: "list" | "tall-list" | "fill"; readonly showBackHint?: boolean; readonly testId?: string; @@ -40,6 +58,7 @@ export function CommandPaletteContent({ footerTrailing, inputAccessory, inputProps, + pendingSelectionRef, panelSize = "list", showBackHint, testId, @@ -49,7 +68,15 @@ export function CommandPaletteContent({
- + { + if (!input) return; + restorePendingSelection(input, pendingSelectionRef); + claimFocusInOpenDialog(input); + }} + /> {inputAccessory}
), icon: projectFaviconIcon, @@ -100,10 +103,17 @@ export function buildNewThreadInProjectItems(input: ProjectItemsInput) { function ProjectLocationDescription(props: { readonly location: CommandPaletteProjectLocation | undefined; readonly workspaceRoot: string; + readonly current?: boolean; }) { const location = props.location ?? UNKNOWN_PROJECT_LOCATION; return ( + {props.current ? ( + <> + Current + + + ) : null} {location.kind === "remote" ? ( {location.label} - {props.workspaceRoot} + {props.workspaceRoot} ); } diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index e3cc3a3791afa..864c190b89c54 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -1,3 +1,5 @@ +import { startPaletteThreadDrag } from "./CommandPalette.drag"; +import { useLegendListRef } from "~/hooks/useLegendListRef"; import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronRightIcon } from "lucide-react"; @@ -76,13 +78,20 @@ 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" }); +export function scrollCommandPaletteRowIntoView( + list: LegendListRef | null, + rowIndex: number, + isFirstItem: boolean, +) { + if (isFirstItem) { + void list?.scrollToOffset({ offset: 0, animated: false }); 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 +105,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 +122,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 +149,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), )} /> @@ -237,6 +250,16 @@ function CommandPaletteResultRow(props: { {...(props.index === undefined ? {} : { index: props.index })} value={props.item.value} active={props.isActive} + onPointerDown={(event) => { + if (props.item.threadRef) { + startPaletteThreadDrag( + event.nativeEvent, + props.item.threadRef, + props.item.searchTitle ?? + (typeof props.item.title === "string" ? props.item.title : "Thread"), + ); + } + }} onMouseDown={(event) => { event.preventDefault(); }} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index a1a69a7968ee9..8da54f5f72141 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -31,9 +31,11 @@ const promptBefore = `See ${serializeAssistantCitation(citation)} `; function Composer({ question, editorRef, + send = "clears", }: { question: boolean; editorRef: React.RefObject; + send?: "clears" | "blocked" | "refusedLater"; }) { const [prompt, setPrompt] = useState(promptBefore); const [answer, setAnswer] = useState(""); @@ -61,6 +63,11 @@ function Composer({ onCitationDraftSave={(replacedValue, savedValue) => { if (replacedValue === prompt) setPrompt(savedValue); }} + onCitationSubmitAndSend={() => { + if (send === "blocked") return false; + if (send === "clears") setPrompt(""); + return true; + }} onPaste={() => {}} /> @@ -97,3 +104,58 @@ it("keeps an unsaved citation comment when a question takes the composer", async ); await expect.element(page.getByLabelText("Edit citation comment")).toBeInTheDocument(); }); + +it("clears the sent prompt when an empty citation comment sends it", async () => { + const editorRef = createRef(); + await using screen = await render(); + + const sourceAnchor = findAssistantCitationSourceAnchor(document, citation); + if (!sourceAnchor) throw new Error("Citation source did not resolve"); + await act(async () => + editorRef.current?.requestCitationComment({ + previousValue: "See ", + value: promptBefore, + citationStart: "See ".length, + sourceAnchor, + insertedSpaces: { before: false, after: true }, + }), + ); + const comment = page.getByLabelText("Comment on selected text"); + await expect.element(comment).toHaveFocus(); + await userEvent.keyboard("{Control>}{Enter}{/Control}"); + + await expect.element(comment).not.toBeInTheDocument(); + await expect.element(page.getByTestId("composer-editor")).toHaveFocus(); + await expect.element(screen.getByRole("status")).toHaveAttribute("data-prompt", ""); +}); + +it.each(["blocked", "refusedLater"] as const)( + "keeps the caret after the citation when save-and-send is %s", + async (send) => { + const editorRef = createRef(); + await using screen = await render( + , + ); + + const sourceAnchor = findAssistantCitationSourceAnchor(document, citation); + if (!sourceAnchor) throw new Error("Citation source did not resolve"); + await act(async () => + editorRef.current?.requestCitationComment({ + previousValue: "See ", + value: promptBefore, + citationStart: "See ".length, + sourceAnchor, + insertedSpaces: { before: false, after: true }, + }), + ); + const comment = page.getByLabelText("Comment on selected text"); + await expect.element(comment).toHaveFocus(); + await userEvent.keyboard("{Control>}{Enter}{/Control}"); + await expect.element(comment).not.toBeInTheDocument(); + await userEvent.keyboard("x"); + + await expect + .element(screen.getByRole("status")) + .toHaveAttribute("data-prompt", `See ${serializeAssistantCitation(citation)}x `); + }, +); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 3ecf2b9994632..88a54373665c5 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 { applyControlledComposerUpdate } from "./composerControlledUpdate"; @@ -21,6 +22,7 @@ import { Plugin, PluginKey, TextSelection, + type Transaction, } from "@tiptap/pm/state"; import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; import type { NodeType, ResolvedPos } from "@tiptap/pm/model"; @@ -47,6 +49,7 @@ import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, type RefObject, } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; @@ -91,6 +94,7 @@ import { skillChipReplacements, splitOrLiftListItem, stepCaretAcrossStyledEdge, + type IsKnownModel, type SkillMeta, } from "~/composer-rich-text-doc"; import { @@ -115,6 +119,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 { @@ -162,6 +167,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; @@ -181,7 +188,7 @@ export interface ComposerPromptEditorProps { onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; - onCitationSubmitAndSend?: () => void; + onCitationSubmitAndSend?: () => boolean; /** Saves unsaved citation text into replacedValue when a controlled rewrite replaces the document. */ onCitationDraftSave?: (replacedValue: string, savedValue: string) => void; onPaste: React.ClipboardEventHandler; @@ -208,12 +215,12 @@ const ComposerCitationCommentContext = createContext<{ openComment: OpenCitationComment | null; onOpenChange: (citeKey: string, open: boolean) => void; onDraftChange: (citeKey: string, comment: string) => void; - onSubmitAndSend: () => void; + onSubmitAndSend: () => boolean; }>({ openComment: null, onOpenChange: () => {}, onDraftChange: () => {}, - onSubmitAndSend: () => {}, + onSubmitAndSend: () => false, }); const RichComposerSkillsContext = createContext>([]); @@ -325,6 +332,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, @@ -433,13 +479,23 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { editor.chain().focus().deleteRange({ from, to }).run(); }; + const sentRef = useRef(false); const onRestoreFocus = () => { + const sent = sentRef.current; + sentRef.current = false; if (!editor.isEditable) return; const pos = nodePos(); - if (pos === null) return; - const current = editor.state.doc.nodeAt(pos); - if (!current || current.type.name !== "composer-citation") return; - editor.commands.focus(pos + current.nodeSize); + const current = pos === null ? null : editor.state.doc.nodeAt(pos); + if (pos === null || current?.type.name !== "composer-citation") { + if (sent) editor.view.focus(); + return; + } + // The send's controlled rewrite may still be pending; emitting this caret move would restore the old prompt. + editor + .chain() + .setMeta(SILENT_SELECTION_META, sent) + .focus(pos + current.nodeSize) + .run(); }; return ( @@ -468,7 +524,6 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { > commentContext.onDraftChange(citeKey, comment), onSaveAndSend: (comment) => { if (!onSaveComment(comment)) return false; - commentContext.onSubmitAndSend(); + sentRef.current = commentContext.onSubmitAndSend(); return true; }, onRestoreFocus, @@ -685,6 +740,8 @@ function chipSelectionDecorations({ doc, selection }: EditorState): DecorationSe type TiptapEditor = NonNullable>; +const SILENT_SELECTION_META = "composerSilentSelection"; + function replaceSkillChips( editor: TiptapEditor, replacements: ReturnType, @@ -770,6 +827,7 @@ function createComposerExtensions(richText: boolean) { ComposerTextDirectionExtension, ComposerMentionExtension, ComposerSkillExtension, + ComposerModelExtension, ComposerCitationExtension, ComposerContextReferenceExtension, ComposerChipSelectionExtension, @@ -877,6 +935,7 @@ function ComposerPromptEditorTiptapInner( containerClassName, className, placeholderClassName, + placeholderSuffix, onChange, onVisibleSelectionChange, onCommandKeyDown, @@ -910,8 +969,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)); @@ -944,10 +1008,10 @@ function ComposerPromptEditorTiptapInner( onDraftChange: (nodeKey: string, comment: string) => { citationDraftRef.current = { key: nodeKey, comment }; }, - onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), + onSubmitAndSend: onCitationSubmitAndSend ?? (() => false), }; - const handleEditorChange = (updated: TiptapEditor) => { + const handleEditorChange = (updated: TiptapEditor, transaction: Transaction) => { const map = serializeEditorDoc(updated.state.doc); const contextIds = literalText ? [] : [...map.contextIds]; const { from, to } = updated.state.selection; @@ -974,7 +1038,7 @@ function ComposerPromptEditorTiptapInner( previousSnapshot.contextIds.length === contextIds.length && previousSnapshot.contextIds.every((id, index) => id === contextIds[index]) ); - if (isApplyingControlledUpdateRef.current) return; + if (isApplyingControlledUpdateRef.current || transaction.getMeta(SILENT_SELECTION_META)) return; if (!snapshotChanged) { if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { readCallbacks().onVisibleSelectionChange?.(); @@ -1040,6 +1104,7 @@ function ComposerPromptEditorTiptapInner( content: buildDocJson(value, (name) => composerSkillMeta(skills, name), { styling: richText, literalText, + isKnownModel: (instanceId, model) => modelKeys.has(modelMentionKey(instanceId, model)), }), editable: !disabled, editorProps: { @@ -1337,7 +1402,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 += " "; @@ -1361,7 +1427,7 @@ function ComposerPromptEditorTiptapInner( insertMarkdownParagraphs( text, skillLabelFor, - { styling: richText, blocks: !nested, literalText, literalLength }, + { styling: richText, blocks: !nested, literalText, literalLength, isKnownModel }, (content) => { editorInstance .chain() @@ -1394,20 +1460,22 @@ 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; } } - handleEditorChange(updated); + handleEditorChange(updated, transaction); }, - onSelectionUpdate: ({ editor: updated }) => { - handleEditorChange(updated); + onSelectionUpdate: ({ editor: updated, transaction }) => { + handleEditorChange(updated, transaction); }, }, [], @@ -1519,7 +1587,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, }, @@ -1558,18 +1626,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(() => { @@ -1785,6 +1856,7 @@ function ComposerPromptEditorTiptapInner( )} > {placeholder} + {placeholderSuffix}
) : null}
@@ -1798,7 +1870,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/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index 8992dc43d7c1c..514c719dba73d 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -9,6 +9,7 @@ import { import { AlertDialog, AlertDialogClose, + AlertDialogAction, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, @@ -59,6 +60,7 @@ export function ConfirmDialogHost() { const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message); const confirmVariant = state.status === "idle" ? "default" : state.variant; + const focusConfirm = state.status !== "idle" && state.initialFocus === "confirm"; const onCancel = () => respondToConfirmDialog(false); const onConfirm = () => respondToConfirmDialog(true); @@ -83,9 +85,13 @@ export function ConfirmDialogHost() { }>Cancel - + {focusConfirm && confirmVariant === "default" ? ( + Confirm + ) : ( + + )} 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/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 91d4a54fd9e64..6781cf1b4a091 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -85,7 +85,7 @@ function CustomSnoozeDialog() { if (!open) finish(null); }} > - +
{ diff --git a/apps/web/src/components/DiffFilePathCopyButton.tsx b/apps/web/src/components/DiffFilePathCopyButton.tsx index ab42f126189ec..5e78949336cff 100644 --- a/apps/web/src/components/DiffFilePathCopyButton.tsx +++ b/apps/web/src/components/DiffFilePathCopyButton.tsx @@ -1,14 +1,12 @@ -import { Check, Copy } from "lucide"; +import { CheckIcon, CopyIcon } from "lucide-react"; import { useRef } from "react"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; -import { cn } from "../lib/utils"; import { ANCHORED_COPY_TOAST_TIMEOUT_MS, showAnchoredCopyErrorToast, showAnchoredCopySuccessToast, } from "./ui/anchoredCopyToast"; import { Button } from "./ui/button"; -import { MorphIcon } from "~/components/MorphIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export function DiffFilePathCopyButton({ filePath }: { filePath: string }) { @@ -32,10 +30,7 @@ export function DiffFilePathCopyButton({ filePath }: { filePath: string }) { /> } > - + {isCopied ? : }

{isCopied ? "Copied" : "Copy path"}

diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 3157c3e5da2a7..5f789469af763 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -79,11 +79,13 @@ vi.mock("~/hooks/useTheme", () => ({ vi.mock("~/state/queries", () => ({ useCheckpointDiff: () => ({ data: null, error: null, isPending: false }), useTurnItemDetail: () => ({ data: null, error: null, isPending: false }), + useDebouncedValue: (value: A) => value, })); vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => {} })); vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => async () => {} })); vi.mock("~/fileContextMenu", () => ({ useFileContextMenuHandler: () => () => {} })); vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => async () => {} })); +vi.mock("./diffs/DiffCommentAnnotation", () => ({ DiffCommentAnnotation: () => null })); vi.mock("./diffs/DiffFileTree", () => ({ DiffFileTree: () => null })); vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null })); @@ -119,7 +121,6 @@ function setup(failedBaseRef: string | null) { useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {}, - keepSelectionOnOpenThreadKey: null, }); useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} }); }; @@ -250,7 +251,7 @@ it.each([false, true])( const toggle = screen.getByRole("button", { name: `Expand ${path}`, exact: true }); await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); await userEvent.click(toggle); - act(() => + await act(() => context.registry.set( context.patch, AsyncResult.success({ diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f40760f847ef0..9767972d73d05 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -17,8 +17,10 @@ import { PilcrowIcon, Rows3Icon, TextWrapIcon, + ChevronRightIcon, + ChevronsDownUpIcon, + ChevronsUpDownIcon, } from "lucide-react"; -import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; import { type MouseEvent, useEffect, useRef, useState } from "react"; import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; @@ -70,7 +72,6 @@ import { DiffFileTree } from "./diffs/DiffFileTree"; import { FileExplorerPane } from "./files/FileExplorerPane"; import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; import { Button } from "./ui/button"; -import { MorphIcon } from "~/components/MorphIcon"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; import { @@ -172,10 +173,11 @@ function DiffFileCollapseToggle({ /> } > - + {collapsed ? ( + + ) : ( + + )} {collapsed ? "Expand diff" : "Collapse diff"} @@ -230,7 +232,8 @@ export default function DiffPanel({ })); const diffLayout = settings.diffLayout; const [wordWrap, setWordWrap] = useState(settings.wordWrap); - const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); + const diffIgnoreWhitespace = + useDiffPanelSessionStore((state) => state.ignoreWhitespace) ?? settings.diffIgnoreWhitespace; const [fileTreeOpen, setFileTreeOpen] = useLocalStorage( DIFF_FILE_TREE_STORAGE_KEY, false, @@ -1159,10 +1162,11 @@ export default function DiffPanel({ /> } > - + {allDiffFilesCollapsed ? ( + + ) : ( + + )} {allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"} @@ -1219,7 +1223,7 @@ export default function DiffPanel({ size="sm" pressed={diffIgnoreWhitespace} onPressedChange={(pressed) => { - setDiffIgnoreWhitespace(Boolean(pressed)); + useDiffPanelSessionStore.getState().setIgnoreWhitespace(Boolean(pressed)); }} /> } @@ -1425,6 +1429,8 @@ export default function DiffPanel({ > { false, ); }); + + it("offers the actions menu only when something in it is usable", () => { + const hasContent = ( + gitStatus: VcsStatusResult, + overrides: Partial[0]> = {}, + ) => + hasUsableGitMenuContent({ + menuItems: buildMenuItems(gitStatus, false, gitStatus.hasPrimaryRemote), + canWriteSourceControl: true, + canPublishRepository: false, + hasNotice: false, + ...overrides, + }); + const clean = status(); + const dirty = status({ hasWorkingTreeChanges: true }); + + assert.isFalse(hasContent(clean)); + assert.isTrue(hasContent(dirty)); + assert.isTrue(hasContent(status({ aheadCount: 1 }))); + assert.isFalse(hasContent(dirty, { canWriteSourceControl: false })); + assert.isTrue(hasContent(status({ hasPrimaryRemote: false }), { canPublishRepository: true })); + assert.isTrue(hasContent(clean, { hasNotice: true })); + }); }); describe("when: on default ref without open PR", () => { @@ -283,6 +307,37 @@ describe("resolveChangesTotals", () => { ); }); + it("uses the panel's branch comparison when it has file stats", () => { + const dirty = status({ + branchChanges: { baseRef: "main", insertions: 20, deletions: 3 }, + }); + const source = { + id: "branch-range", + kind: "branch-range", + title: "Changes vs release", + baseRef: "release", + headRef: "feature/test", + diff: "", + diffHash: "hash", + truncated: false, + } as const; + assert.deepEqual( + resolveChangesTotals(dirty, { + ...source, + files: [ + { path: "a.ts", previousPath: null, additions: 4, deletions: 1 }, + { path: "b.ts", previousPath: null, additions: 2, deletions: 0 }, + ], + }), + { insertions: 6, deletions: 1, baseRef: "release" }, + ); + assert.deepEqual(resolveChangesTotals(dirty, source), { + insertions: 20, + deletions: 3, + baseRef: "main", + }); + }); + it("falls back to the working tree when branchChanges is omitted", () => { assert.deepInclude( resolveChangesTotals( diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 5effb44c79a87..399e013068364 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,6 +1,7 @@ import type { GitRunStackedActionResult, GitStackedAction, + ReviewDiffPreviewSource, VcsStatusResult, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; @@ -174,6 +175,19 @@ export function buildMenuItems( ]; } +export function hasUsableGitMenuContent(input: { + readonly menuItems: readonly GitActionMenuItem[]; + readonly canWriteSourceControl: boolean; + readonly canPublishRepository: boolean; + readonly hasNotice: boolean; +}): boolean { + return ( + input.canPublishRepository || + input.hasNotice || + (input.canWriteSourceControl && input.menuItems.some((item) => !item.disabled)) + ); +} + export function resolveQuickAction( gitStatus: VcsStatusResult | null, isBusy: boolean, @@ -435,9 +449,21 @@ export type ChangesLineTotals = { // `branchChanges ?? workingTree` treats `{0,0}` as present, so a zero branch total hid a dirty // working tree and the Changes row read +0 while Diff still showed the work. +// The Diff panel's branch comparison wins when it has file stats, so a remembered base or +// hidden whitespace reads the same in the row as in the panel. export function resolveChangesTotals( status: VcsStatusResult | null | undefined, + branchPreview?: ReviewDiffPreviewSource | null, ): ChangesLineTotals | null { + if (branchPreview?.files) { + let insertions = 0; + let deletions = 0; + for (const file of branchPreview.files) { + insertions += file.additions; + deletions += file.deletions; + } + return { insertions, deletions, baseRef: branchPreview.baseRef }; + } if (!status) return null; const branch = status.branchChanges; const tree = { ...status.workingTree, baseRef: null }; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index e290e5fb8e2b6..e11e63f2a1a2a 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -46,6 +46,7 @@ import { buildMenuItems, formatGitActionElapsed, GIT_ACTION_SUCCESS_VISIBLE_MS, + hasUsableGitMenuContent, type GitActionProgressPresentation, type GitActionIconName, type GitActionMenuItem, @@ -93,6 +94,15 @@ import { import { useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; +import { useFilesystemReadAccess } from "~/state/filesystem"; +import { reviewEnvironment } from "~/state/review"; +import { revisionedQuery } from "~/state/revisionedQuery"; +import { + selectThreadBranchBaseRef, + useDiffPanelSessionStore, + useDiffPanelStore, +} from "~/diffPanelStore"; +import { useClientSettings } from "~/hooks/useSettings"; import { serverEnvironment } from "~/state/server"; import { sourceControlEnvironment } from "~/state/sourceControl"; import { threadEnvironment } from "~/state/threads"; @@ -121,6 +131,7 @@ interface GitActionsControlProps { showActions?: boolean; showChanges?: boolean; onOpenChanges?: () => void; + workspaceMutationId?: string | null; } interface PendingDefaultBranchAction { @@ -932,6 +943,7 @@ export default function GitActionsControl({ showActions = true, showChanges = true, onOpenChanges, + workspaceMutationId = null, }: GitActionsControlProps) { const panelAnchorRef = useRef(null); const updateThreadMetadata = useAtomCommand( @@ -1049,7 +1061,53 @@ export default function GitActionsControl({ const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; - const resolvedChangesTotals = resolveChangesTotals(gitStatusForActions); + const changesBaseRef = useDiffPanelStore((state) => + selectThreadBranchBaseRef(state, activeThreadRef), + ); + const ignoreWhitespaceSetting = useClientSettings((settings) => settings.diffIgnoreWhitespace); + const ignoreWhitespace = + useDiffPanelSessionStore((state) => state.ignoreWhitespace) ?? ignoreWhitespaceSetting; + const { canReadFiles } = useFilesystemReadAccess(activeEnvironmentId); + // Same input as the Diff panel's branch query, so both read one cached result. Status + // totals join the revision because edits, pulls and resets outside a turn leave the mutation + // id as is. + const changesRevision = gitStatusForActions + ? JSON.stringify([ + workspaceMutationId, + gitStatusForActions.refName, + gitStatusForActions.workingTree.insertions, + gitStatusForActions.workingTree.deletions, + gitStatusForActions.branchChanges?.baseRef, + gitStatusForActions.branchChanges?.insertions, + gitStatusForActions.branchChanges?.deletions, + ]) + : null; + const changesPreview = useEnvironmentQuery( + showChanges && + isRepo && + canReadFiles && + changesRevision !== null && + activeEnvironmentId !== null && + gitCwd !== null + ? revisionedQuery( + reviewEnvironment.diffPreview({ + environmentId: activeEnvironmentId, + input: { + cwd: gitCwd, + ...(changesBaseRef ? { baseRef: changesBaseRef } : {}), + ignoreWhitespace, + metadataOnly: true, + sourceKind: "branch-range", + }, + }), + changesRevision, + ) + : null, + ); + const resolvedChangesTotals = resolveChangesTotals( + gitStatusForActions, + changesPreview.data?.sources.find((source) => source.kind === "branch-range"), + ); const changesTotals = resolvedChangesTotals ?? { insertions: 0, deletions: 0 }; const changesBaseLabel = resolvedChangesTotals ? resolvedChangesTotals.baseRef === null @@ -1395,6 +1453,23 @@ export default function GitActionsControl({ const canPublishRepository = canWriteSourceControl && isRepo && gitStatusForActions !== null && !hasPrimaryRemote; + const isDetachedHead = gitStatusForActions?.refName === null; + const isCleanAndBehind = + gitStatusForActions !== null && + gitStatusForActions.refName !== null && + !gitStatusForActions.hasWorkingTreeChanges && + gitStatusForActions.behindCount > 0 && + gitStatusForActions.aheadCount === 0; + const hasGitMenuContent = hasUsableGitMenuContent({ + menuItems: gitActionMenuItems, + canWriteSourceControl, + canPublishRepository, + hasNotice: isDetachedHead || isCleanAndBehind || Boolean(gitStatusError), + }); + + // A disabled menu cannot refresh on open, and terminal edits or commits publish no status. + const refreshStatusForEmptyMenu = () => + requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); const initializeGit = () => { void (async () => { @@ -1466,19 +1541,15 @@ export default function GitActionsControl({ Publish repository... ) : null} - {gitStatusForActions?.refName === null && ( + {isDetachedHead && (

Detached HEAD: create and check out a branch to enable push and{" "} {changeRequestTerminology.singular} actions.

)} - {gitStatusForActions && - gitStatusForActions.refName !== null && - !gitStatusForActions.hasWorkingTreeChanges && - gitStatusForActions.behindCount > 0 && - gitStatusForActions.aheadCount === 0 && ( -

Behind upstream. Pull/rebase first.

- )} + {isCleanAndBehind && ( +

Behind upstream. Pull/rebase first.

+ )} {gitStatusError &&

{gitStatusError}

} ); @@ -1506,6 +1577,8 @@ export default function GitActionsControl({ role="group" aria-label="Git actions" ref={panelAnchorRef} + onPointerEnter={hasGitMenuContent ? undefined : refreshStatusForEmptyMenu} + onFocus={hasGitMenuContent ? undefined : refreshStatusForEmptyMenu} className={cn( "shrink-0", THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, @@ -1613,7 +1686,7 @@ export default function GitActionsControl({ panel /> } - disabled={isGitActionRunning} + disabled={isGitActionRunning || !hasGitMenuContent} >