diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 69c1ea6575a4a..315ff5742fd5c 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -22,6 +22,7 @@ github:btsouth github:cestercian github:chrisdeeming github:chuks-qua +-github:coygeek Filed over 270 automated issues, about 50 a day at peak, far past what maintainers can triage github:cursoragent github:D3OXY github:dbalders diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e27a8c90fdff8..cc6dc80511aca 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -35,7 +35,7 @@ runs: - name: Cache Vite+ id: vp_cache if: steps.vp.outputs.version != '' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.vite-plus key: vite-plus-home-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }}-node${{ steps.vp.outputs.node }}-${{ steps.vp.outputs.pnpm }} @@ -56,7 +56,7 @@ runs: # partial bytes, so a shorter limit costs a slow download only a retry. - name: Setup Vite+ if: steps.vp_cache.outputs.cache-hit != 'true' - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 env: VP_DOWNLOAD_TIMEOUT: "120" with: @@ -92,7 +92,7 @@ runs: - name: Restore pnpm store id: pnpm_cache if: inputs.install == 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} @@ -106,7 +106,7 @@ runs: - name: Restore lockfile verification id: pnpm_verified if: inputs.install == 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.verified }} key: pnpm-verified-v1-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} @@ -121,7 +121,7 @@ runs: - name: Save lockfile verification if: inputs.install == 'true' && steps.pnpm_verified.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.verified }} key: ${{ steps.pnpm_verified.outputs.cache-primary-key }} @@ -138,7 +138,7 @@ runs: 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'))) - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: ${{ steps.pnpm_cache.outputs.cache-primary-key }} diff --git a/.github/readme/hero.webp b/.github/readme/hero.webp new file mode 100644 index 0000000000000..8cc713bf541d5 Binary files /dev/null and b/.github/readme/hero.webp differ diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index 95581e2b22781..a9502b26c8c81 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -121,9 +121,54 @@ async function resolveLatestNightlyCommit({ github, context, core }) { return { tag, sha: commit.sha, version }; } +// The two places a nightly install looks for an update: the `update-feed` pointer, and +// releases.atom, which holds the 10 newest git tags of any kind. Older installs take the first +// nightly in the feed. Reports what each names beside the newest published nightly. +async function readNightlyUpdateFeed({ github, context, fetch = globalThis.fetch }) { + const latest = await findLatestNightly({ github, context }); + if (!latest) return {}; + + const base = `https://github.com/${context.repo.owner}/${context.repo.repo}`; + // Only a missing or malformed pointer is stale. Rewriting deletes the old file first, so a + // GitHub outage must not look like one. + // Node waits five minutes for a stalled response, which is this job's whole time limit. + const signal = () => AbortSignal.timeout(30_000); + const pointer = await fetch(`${base}/releases/download/update-feed/nightly.json`, { + signal: signal(), + }); + if (!pointer.ok && pointer.status !== 404) { + throw new Error(`The update-feed pointer answered HTTP ${pointer.status}.`); + } + let pointerTag; + if (pointer.ok) { + // A body that fails to arrive throws here; only one that is not JSON counts as malformed. + const body = await pointer.text(); + try { + pointerTag = JSON.parse(body)?.tag_name; + } catch {} + } + + // An unreadable feed leaves `feedTags` unset, so the pointer is still repaired. An error page + // can answer HTTP 200, so only an Atom document counts as read. + let feedTags; + try { + const feed = await fetch(`${base}/releases.atom`, { signal: signal() }); + const body = feed.ok ? await feed.text() : ""; + if (/]/.test(body)) { + feedTags = Array.from(body.matchAll(/href="[^"]*\/releases\/tag\/([^"]+)"/g), (match) => + decodeURIComponent(match[1]), + ); + } + } catch {} + const feedNightly = feedTags?.find((tag) => /^v?\d+\.\d+\.\d+-nightly\./.test(tag)); + + return { tag: latest.tagName, pointerTag, feedNightly, feedTags }; +} + module.exports = { assertCommitOnDefaultBranch, assertReleaseSource, shouldReleaseNightly, resolveLatestNightlyCommit, + readNightlyUpdateFeed, }; diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index 3030080d946ce..2f3d385755ca6 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -237,3 +237,81 @@ 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/); }); + +const { readNightlyUpdateFeed } = require("./check-nightly-release.cjs"); + +// GitHub percent-encodes tag names in the feed, and any tag in the repository takes a slot. +const atomEntry = (tag) => + ``; +const atomFeed = (tags) => new Response(`${tags.map(atomEntry).join("")}`); +const NEWEST = "v1.0.1-nightly.20260905.123"; +const OLDER = "v1.0.1-nightly.20260905.100"; + +function updateFeedFixture({ feed, pointer }) { + const { options } = fixture({ + releases: [nightly(1), nightly(0, { tagName: "v1.0.2-preview.1" })], + }); + options.fetch = async (url) => + url === "https://github.com/example/app/releases.atom" + ? feed + : url === "https://github.com/example/app/releases/download/update-feed/nightly.json" + ? pointer + : assert.fail(`unexpected fetch ${url}`); + return options; +} + +const missing = () => new Response("Not Found", { status: 404 }); +// HTTP 200, then the connection drops before the body arrives. +const brokenBody = () => + new Response(new ReadableStream({ start: (controller) => controller.error(new Error("reset")) })); +for (const [name, { feed, pointer }, expected] of [ + [ + "a nightly crowded out of the feed and a missing pointer", + { feed: atomFeed(["v1.0.2-preview.1", "mobile-android-build/2.3.0/abc"]), pointer: missing() }, + { pointerTag: undefined, feedNightly: undefined }, + ], + [ + "the first nightly in the feed, which is the one older installs take", + { feed: atomFeed(["update-feed", OLDER, NEWEST]), pointer: Response.json({ tag_name: OLDER }) }, + { pointerTag: OLDER, feedNightly: OLDER }, + ], + [ + "a malformed pointer as stale and an unreadable feed as naming nothing", + { feed: new Response("", { status: 503 }), pointer: Response.json(null) }, + { pointerTag: undefined, feedNightly: undefined, unreadable: true }, + ], + [ + "an HTML page with release links as an unreadable feed", + { + feed: new Response( + `x`, + ), + pointer: Response.json({ tag_name: NEWEST }), + }, + { pointerTag: NEWEST, feedNightly: undefined, unreadable: true }, + ], + [ + "the pointer when the feed's body fails to arrive", + { feed: brokenBody(), pointer: Response.json({ tag_name: NEWEST }) }, + { pointerTag: NEWEST, feedNightly: undefined, unreadable: true }, + ], +]) { + test(`update feed check reports ${name}`, async () => { + const { tag, pointerTag, feedNightly, feedTags } = await readNightlyUpdateFeed( + updateFeedFixture({ feed, pointer }), + ); + const { unreadable = false, ...named } = expected; + assert.deepEqual({ tag, pointerTag, feedNightly }, { tag: NEWEST, ...named }); + assert.equal(feedTags === undefined, unreadable); + }); +} + +for (const [name, pointer, error] of [ + ["answers with an error", () => new Response("", { status: 503 }), /pointer answered HTTP 503/], + ["drops the connection", brokenBody, /reset/], +]) { + test(`update feed check does not call a pointer stale when GitHub ${name}`, async () => { + const options = updateFeedFixture({ feed: atomFeed([NEWEST]), pointer: pointer() }); + await assert.rejects(readNightlyUpdateFeed(options), error); + }); +} diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 09b25131e4c48..304b56d57e400 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -3,6 +3,7 @@ const path = require("node:path"); const ARTIFACT_NAME = "thread-transfer-results"; const RESULT_FILE = "thread-transfer-result.json"; +const SCENARIOS_FILE = "thread-transfer-scenarios.json"; const COMMENT_MARKER = ""; const PROVIDERS = ["codex", "claudeAgent"]; const OBSERVED_KEYS = [ @@ -29,6 +30,12 @@ const SCENARIO_KEYS = [ "measuredMcpResultBytes", ]; +const SCENARIO_ROW_KEYS = ["phase", "metric", "unit", "observed", "ceiling"]; +// These labels come from the PR and reach a comment the bot posts: no markup, links or mentions. +// GitHub still links plain text such as `GH-26`, so they are rendered as code spans. +const SCENARIO_LABEL = /^[A-Za-z0-9][A-Za-z0-9 ,-]{0,79}$/; +const MAX_SCENARIO_ROWS = 100; + function resultShaMarker(sha) { return ``; } @@ -85,15 +92,63 @@ function validateResult(value) { return value; } -function readResult(directory) { +function validateScenarios(value) { + assertExactKeys(value, ["schemaVersion", "providers"], "scenarios"); + if (value.schemaVersion !== 1) { + throw new Error("scenarios.schemaVersion must be 1"); + } + assertExactKeys(value.providers, PROVIDERS, "scenarios.providers"); + for (const provider of PROVIDERS) { + const rows = value.providers[provider]; + if (!Array.isArray(rows) || rows.length > MAX_SCENARIO_ROWS) { + throw new Error( + `scenarios.providers.${provider} must list at most ${MAX_SCENARIO_ROWS} rows`, + ); + } + const seen = new Set(); + for (const [index, row] of rows.entries()) { + const label = `scenarios.providers.${provider}[${index}]`; + assertExactKeys(row, SCENARIO_ROW_KEYS, label); + for (const key of ["phase", "metric"]) { + if (typeof row[key] !== "string" || !SCENARIO_LABEL.test(row[key])) { + throw new Error(`${label}.${key} must be a short plain label`); + } + } + if (row.unit !== "bytes" && row.unit !== "count") { + throw new Error(`${label}.unit must be bytes or count`); + } + assertMetric(row.observed, `${label}.observed`); + assertMetric(row.ceiling, `${label}.ceiling`); + const key = scenarioKey(row); + if (seen.has(key)) throw new Error(`${label} repeats an earlier row`); + seen.add(key); + } + } + return value; +} + +function scenarioKey(row) { + return `${row.phase}|${row.metric}`; +} + +function readArtifactFile(directory, name, validate) { if (!directory) return undefined; - const file = path.join(directory, RESULT_FILE); + const file = path.join(directory, name); if (!fs.existsSync(file)) return undefined; const stat = fs.lstatSync(file); if (!stat.isFile() || stat.size > 64 * 1_024) { - throw new Error("thread transfer result must be a regular file smaller than 64 KiB"); + throw new Error(`${name} must be a regular file smaller than 64 KiB`); } - return validateResult(JSON.parse(fs.readFileSync(file, "utf8"))); + return validate(JSON.parse(fs.readFileSync(file, "utf8"))); +} + +function readResult(directory) { + return readArtifactFile(directory, RESULT_FILE, validateResult); +} + +// Older runs upload only the result file, so a run without scenarios is still a valid run. +function readScenarios(directory) { + return readArtifactFile(directory, SCENARIOS_FILE, validateScenarios); } function formatBytes(bytes) { @@ -103,7 +158,7 @@ function formatBytes(bytes) { } function formatValue(value, kind) { - return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value); + return kind === "bytes" ? formatBytes(value) : value.toLocaleString("en-US"); } function formatImpact(current, baseline, kind) { @@ -136,6 +191,77 @@ const METRICS = [ { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" }, ]; +function providerLabel(provider) { + return provider === "codex" ? "Codex" : "Claude"; +} + +/** + * The scenario rows of a run against the same rows on `main`. Rows that fail or differ from + * `main` are listed in the open; the full table is collapsed below them. + */ +function renderScenarios(current, baseline, ceilingChanges) { + if (!current) return { lines: [], failed: false }; + const header = [ + "| Provider | Scenario | Metric | Main baseline | This PR | Impact | PR ceiling | |", + "| --- | --- | --- | ---: | ---: | ---: | ---: | --- |", + ]; + const all = []; + const notable = []; + let failed = false; + let withoutBaseline = 0; + + for (const provider of PROVIDERS) { + const baselineRows = new Map( + (baseline?.providers[provider] ?? []).map((row) => [scenarioKey(row), row]), + ); + for (const row of current.providers[provider]) { + const before = baselineRows.get(scenarioKey(row)); + const beforeObserved = before?.unit === row.unit ? before.observed : undefined; + const pass = row.observed <= row.ceiling; + failed ||= !pass; + if (beforeObserved === undefined) withoutBaseline += 1; + const line = `| ${providerLabel(provider)} | \`${row.phase}\` | \`${row.metric}\` | ${beforeObserved === undefined ? "—" : formatValue(beforeObserved, row.unit)} | ${formatValue(row.observed, row.unit)} | ${formatImpact(row.observed, beforeObserved, row.unit)} | ${formatValue(row.ceiling, row.unit)} | ${pass ? "✅" : "❌"} |`; + all.push(line); + if (!pass || (beforeObserved !== undefined && beforeObserved !== row.observed)) { + notable.push(line); + } + if (beforeObserved !== undefined && before.ceiling !== row.ceiling) { + ceilingChanges.push( + `- ${providerLabel(provider)} \`${row.phase}\` \`${row.metric}\`: ${formatValue(before.ceiling, row.unit)} → ${formatValue(row.ceiling, row.unit)}`, + ); + } + } + } + + const compared = all.length - withoutBaseline; + const summary = !baseline + ? `No \`main\` baseline exists yet for these ${all.length} scenario measurements.` + : [ + notable.length > 0 + ? `${notable.length} of ${all.length} scenario measurements changed or failed.` + : `${compared === all.length ? "All " : ""}${compared} scenario measurements match \`main\`.`, + ...(withoutBaseline > 0 ? [`${withoutBaseline} have no \`main\` baseline.`] : []), + ].join(" "); + return { + failed, + lines: [ + "### Other scenarios", + "", + summary, + ...(notable.length > 0 ? ["", ...header, ...notable] : []), + "", + "
", + `All ${all.length} scenario measurements`, + "", + ...header, + ...all, + "", + "
", + "", + ], + }; +} + function renderComment(input) { const current = input.current; const baseline = input.baseline; @@ -154,16 +280,22 @@ function renderComment(input) { const pass = observed <= ceiling; failed ||= !pass; rows.push( - `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, + `| ${providerLabel(provider)} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, ); if (comparable && baseline.providers[provider].ceiling[metric.key] !== ceiling) { ceilingChanges.push( - `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, + `- ${providerLabel(provider)} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, ); } } } + const scenarios = renderScenarios( + input.currentScenarios, + input.baselineScenarios, + ceilingChanges, + ); + failed ||= scenarios.failed; const baselineLink = input.baselineRun ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})` @@ -203,6 +335,7 @@ function renderComment(input) { "| --- | --- | ---: | ---: | ---: | ---: | --- |", ...rows, "", + ...scenarios.lines, `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`, "", "
", @@ -212,7 +345,7 @@ function renderComment(input) { "", ...PROVIDERS.map( (provider) => - `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, + `- ${providerLabel(provider)} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, ), "", "
", @@ -437,7 +570,14 @@ async function publish({ github, context, core }) { url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`, } : undefined; - const body = renderComment({ current, baseline, currentRun, baselineRun }); + const body = renderComment({ + current, + baseline, + currentScenarios: readScenarios(process.env.PR_RESULT_DIR), + baselineScenarios: readScenarios(process.env.BASELINE_RESULT_DIR), + currentRun, + baselineRun, + }); const published = await upsertCommentForCurrentHead( github, context, @@ -458,4 +598,5 @@ module.exports = { resolve, upsertCommentForCurrentHead, validateResult, + validateScenarios, }; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 8c4595ccf0c6a..3a953a8bfc16b 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -2,9 +2,11 @@ const assert = require("node:assert/strict"); const test = require("node:test"); const { + renderComment, resolve, upsertCommentForCurrentHead, validateResult, + validateScenarios, } = require("./thread-transfer-report.cjs"); function result(overrides = {}) { @@ -40,6 +42,29 @@ function result(overrides = {}) { }; } +function scenarios(overrides = {}) { + const rows = [ + { + phase: "long turn finished, cold open", + metric: "HTTP decoded", + unit: "bytes", + observed: 390_000, + ceiling: 420_000, + }, + { + phase: "idle thread resume", + metric: "WebSocket messages", + unit: "count", + observed: 1, + ceiling: 1, + }, + ]; + return { + schemaVersion: 1, + providers: { codex: [{ ...rows[0], ...overrides }, rows[1]], claudeAgent: rows }, + }; +} + test("validates the fixed artifact schema", () => { assert.equal(validateResult(result()).schemaVersion, 1); assert.throws( @@ -50,6 +75,60 @@ test("validates the fixed artifact schema", () => { () => validateResult(result({ totalWireBytes: "lots" })), /non-negative safe integer/, ); + + assert.equal(validateScenarios(scenarios()).schemaVersion, 1); + for (const phase of ["@everyone", "[link](https://example.com)", "a | b", "", ""]) { + assert.throws(() => validateScenarios(scenarios({ phase })), /short plain label/); + } + assert.throws(() => validateScenarios(scenarios({ note: "extra" })), /unexpected fields/); + assert.throws(() => validateScenarios(scenarios({ ceiling: null })), /non-negative safe integer/); + assert.throws( + () => + validateScenarios(scenarios({ phase: "idle thread resume", metric: "WebSocket messages" })), + /repeats an earlier row/, + ); +}); + +test("lists scenario rows that changed or failed above the collapsed table", () => { + const run = { sha: "abcdef1234", url: "https://example.com/run", conclusion: "success" }; + const render = (currentScenarios, baselineScenarios) => + renderComment({ + current: result(), + baseline: result(), + currentScenarios, + baselineScenarios, + currentRun: run, + baselineRun: { ...run, matchesBase: true }, + }); + const open = (body) => body.slice(0, body.indexOf("
")); + + const unchanged = render(scenarios(), scenarios()); + assert.match(unchanged, /✅ Thread transfer remains within every enforced ceiling/); + assert.match(open(unchanged), /All 4 scenario measurements match `main`/); + assert.doesNotMatch(open(unchanged), /long turn finished, cold open/); + + const grown = render(scenarios({ observed: 400_000 }), scenarios()); + assert.match(open(grown), /1 of 4 scenario measurements changed or failed/); + assert.match( + open(grown), + /\| Codex \| `long turn finished, cold open` \| `HTTP decoded` \| 380\.9 KiB \| 390\.6 KiB \| \+9\.8 KiB \(\+2\.6%\) \| 410\.2 KiB \| ✅ \|/, + ); + + const over = render(scenarios({ observed: 430_000, ceiling: 425_000 }), scenarios()); + assert.match(over, /❌ One or more thread transfer ceilings were exceeded/); + assert.match( + over, + /Codex `long turn finished, cold open` `HTTP decoded`: 410\.2 KiB → 415\.0 KiB/, + ); + + // A row `main` does not have is not reported as matching it. + const added = render(scenarios({ phase: "GH-26" }), scenarios()); + assert.match(open(added), /3 scenario measurements match `main`\. 1 have no `main` baseline\./); + assert.match(added, /\| Codex \| `GH-26` \|/); + + // A main run from before the scenario file has nothing to compare; a PR run from before it has no section. + assert.match(open(render(scenarios(), undefined)), /No `main` baseline exists yet/); + assert.doesNotMatch(render(undefined, scenarios()), /Other scenarios/); }); test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { diff --git a/.github/workflows/agent-context-budget.yml b/.github/workflows/agent-context-budget.yml index 41efb7b69a5a2..5935fb4653c32 100644 --- a/.github/workflows/agent-context-budget.yml +++ b/.github/workflows/agent-context-budget.yml @@ -10,10 +10,7 @@ on: permissions: contents: read -concurrency: - group: agent-context-budget-${{ github.event.pull_request.number }} - cancel-in-progress: true - +# No concurrency group: label events arrive in bursts, and a cancelled run reports as a failed check. jobs: budget: name: Agent context budget @@ -24,7 +21,7 @@ jobs: ALLOW_INCREASE: ${{ contains(github.event.pull_request.labels.*.name, 'agent-context-increase') }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: apps/server/src/provider/agentContextBudget.json sparse-checkout-cone-mode: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1593bb5bcf7fc..d86b614ec4bfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: # Only the package manifests the script reads to build the dependency graph. # Depth 2 brings the base commit the change is diffed against. - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 2 sparse-checkout: | @@ -68,7 +68,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -92,7 +92,7 @@ jobs: # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. - name: Cache Electron download - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/electron key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} @@ -127,7 +127,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -164,7 +164,7 @@ jobs: # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. - name: Cache Electron download - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/electron key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} @@ -216,7 +216,7 @@ jobs: - name: Upload desktop launch check if: ${{ failure() && steps.desktop_launch.outcome == 'failure' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: desktop-launch-check path: ${{ runner.temp }}/desktop-launch-check @@ -234,7 +234,7 @@ jobs: - name: Cache Chromium id: chromium_cache if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} @@ -282,7 +282,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -317,7 +317,7 @@ jobs: # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. - name: Cache Electron download - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/electron key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} @@ -376,7 +376,7 @@ jobs: shard: [1, 2] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -397,7 +397,7 @@ jobs: - name: Cache Chromium id: chromium_cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} @@ -441,7 +441,7 @@ jobs: steps: # Depth 2 brings the base commit the change is diffed against. - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 2 sparse-checkout: | @@ -472,7 +472,7 @@ jobs: - name: Cache Chromium id: chromium_cache if: steps.changes.outputs.test_server != 'false' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} @@ -495,7 +495,7 @@ jobs: # Only pushes to main save it, so pull requests never evict each other's entries. - name: Restore Vitest module cache if: steps.changes.outputs.test_server != 'false' - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: node_modules/.vitest-cache key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} @@ -506,14 +506,16 @@ jobs: env: T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json + T3CODE_TRANSFER_BUDGET_SCENARIOS_PATH: ${{ runner.temp }}/thread-transfer-scenarios.json + # One process per file spends ~2s starting before its tests run; one file per vCPU overlaps that. run: >- - vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }} + vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }} --concurrency 4 --uncached apps/server/integration/transferBudgetV2.integration.test.ts --verify-hits 1 apps/server - name: Save Vitest module cache if: github.event_name == 'push' && steps.changes.outputs.test_server != 'false' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: node_modules/.vitest-cache key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} @@ -543,10 +545,12 @@ jobs: - name: Upload thread transfer result if: always() && steps.transfer_budget.outputs.present == 'true' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: thread-transfer-results - path: ${{ runner.temp }}/thread-transfer-result.json + path: | + ${{ runner.temp }}/thread-transfer-result.json + ${{ runner.temp }}/thread-transfer-scenarios.json if-no-files-found: ignore retention-days: 30 @@ -560,7 +564,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -569,8 +573,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: + toolchain: stable components: rustfmt - name: Check Rust formatting @@ -593,7 +598,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -601,7 +606,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -626,7 +631,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -684,7 +689,7 @@ jobs: # server tests ran must have produced it. - name: Require transfer measurements if: needs.test_server.result == 'success' && needs.changes.outputs.test_server != 'false' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: thread-transfer-results path: ${{ runner.temp }}/thread-transfer diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 5db7a86eb98c9..da779c2783d4c 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -51,7 +51,7 @@ jobs: ALCHEMY_TELEMETRY_DISABLED: "1" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -59,7 +59,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -83,7 +83,7 @@ jobs: T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} - name: Publish relay deploy commit status - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | await github.rest.repos.createCommitStatus({ diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index 6e8805b7f790b..a9615439929fe 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -55,7 +55,7 @@ jobs: steps: - id: pr name: Resolve the pull request behind the build - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const run = context.payload.workflow_run; @@ -134,7 +134,7 @@ jobs: - id: vouch name: Check PR author trust if: steps.pr.outputs.eligible == 'true' - uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1 + uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: user: ${{ steps.pr.outputs.author }} allow-fail: true @@ -149,7 +149,7 @@ jobs: - id: consume name: Consume the preview label if: steps.pr.outputs.eligible == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} with: @@ -271,7 +271,7 @@ jobs: # Only validated bundle files cross into the signing job's artifact. - name: Stage JS bundle for packaging if: steps.gate.outputs.eligible == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: js-bundle path: ${{ runner.temp }}/js-bundle @@ -338,7 +338,7 @@ jobs: pull-requests: write steps: - name: Download macOS artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: desktop-mac-arm64 path: release @@ -431,7 +431,7 @@ jobs: - name: Comment download link if: steps.upload.outputs.download_url != '' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} @@ -552,7 +552,7 @@ jobs: - name: Mark the preview comment as removed if: steps.delete.outputs.removed == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const marker = ""; diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 0213f3fce6a99..2c54052ea66d5 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -50,7 +50,7 @@ jobs: git checkout --detach FETCH_HEAD - name: Setup Vite+ - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -98,7 +98,7 @@ jobs: # Same layout as release.yml's js-bundle so release-desktop.yml can # package it unchanged. - name: Upload JS bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: js-bundle path: | diff --git a/.github/workflows/issue-labels.yml b/.github/workflows/issue-labels.yml index d6571d65d4530..fd9ba6b60302b 100644 --- a/.github/workflows/issue-labels.yml +++ b/.github/workflows/issue-labels.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Ensure managed issue labels exist - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const managedLabels = [ diff --git a/.github/workflows/issue-vouch.yml b/.github/workflows/issue-vouch.yml new file mode 100644 index 0000000000000..bbb6e95c9ec60 --- /dev/null +++ b/.github/workflows/issue-vouch.yml @@ -0,0 +1,77 @@ +name: Issue Vouch + +on: + issues: + types: [opened, reopened] + push: + branches: + - main + paths: + - .github/VOUCHED.td + - .github/workflows/issue-vouch.yml + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: issue-vouch-${{ github.event.issue.number || 'all' }} + cancel-in-progress: true + +jobs: + label: + name: Label issues by author trust + runs-on: ubuntu-24.04 + steps: + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { owner, repo } = context.repo; + // Only listed authors get a label; open issues number in the thousands, + // so unvouched is the default and stays unlabelled. + const labelByStatus = { vouched: "vouch:trusted", denounced: "vouch:denounced" }; + const managedLabelNames = new Set(["vouch:trusted", "vouch:unvouched", "vouch:denounced"]); + + const { data: file } = await github.rest.repos.getContent({ + owner, + repo, + path: ".github/VOUCHED.td", + ref: context.payload.repository.default_branch, + }); + const statusByUser = new Map(); + for (const raw of Buffer.from(file.content, "base64").toString("utf8").split("\n")) { + const match = /^(-?)github:(\S+)/.exec(raw.trim()); + if (match) statusByUser.set(match[2].toLowerCase(), match[1] ? "denounced" : "vouched"); + } + + const issues = + context.eventName === "issues" + ? [context.payload.issue] + : ( + await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: "open", + per_page: 100, + }) + ).filter((issue) => !issue.pull_request); + + for (const issue of issues) { + const status = statusByUser.get(issue.user.login.toLowerCase()); + const next = labelByStatus[status]; + const current = issue.labels.map((label) => label.name).filter((name) => managedLabelNames.has(name)); + + for (const name of current) { + if (name === next) continue; + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + if (next && !current.includes(next)) { + await github.rest.issues.addLabels({ owner, repo, issue_number: issue.number, labels: [next] }); + core.info(`Issue #${issue.number} (${issue.user.login}): ${status} -> ${next}`); + } + } diff --git a/.github/workflows/mobile-android-apk.yml b/.github/workflows/mobile-android-apk.yml index 18d977c50f4a0..8650b5a6d527d 100644 --- a/.github/workflows/mobile-android-apk.yml +++ b/.github/workflows/mobile-android-apk.yml @@ -37,7 +37,7 @@ jobs: - name: Checkout if: steps.credentials.outputs.present == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: .github/scripts diff --git a/.github/workflows/mobile-dev-client.yml b/.github/workflows/mobile-dev-client.yml index 5f8cb86befb59..e204b16f01626 100644 --- a/.github/workflows/mobile-dev-client.yml +++ b/.github/workflows/mobile-dev-client.yml @@ -83,7 +83,7 @@ jobs: T3_NATIVE_CLIENT_VERBOSE: "1" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.ref || github.sha }} @@ -144,7 +144,7 @@ jobs: # the runs holding the cache login restore. - name: Mount build cache if: github.event_name != 'pull_request' - uses: useblacksmith/stickydisk@v1 + uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2 with: key: ${{ github.repository }}-android-dev-client-${{ matrix.abi }} path: ~/.cache/t3-android-dev-client @@ -165,7 +165,7 @@ jobs: install-args: --filter=@t3tools/mobile... --filter=@t3tools/scripts... - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: 17 @@ -232,7 +232,7 @@ jobs: T3_NATIVE_CLIENT_VERBOSE: "1" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.ref || github.sha }} diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml index a8bf5725af70c..13ca814defd7e 100644 --- a/.github/workflows/mobile-eas-preview-fork.yml +++ b/.github/workflows/mobile-eas-preview-fork.yml @@ -40,7 +40,7 @@ jobs: # If a maintainer removed the label first, that removal wins. - id: consume name: Consume the deployment label - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | try { @@ -75,7 +75,7 @@ jobs: # the PR's scripts to read. - name: Checkout reviewed commit if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -84,7 +84,7 @@ jobs: # 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 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: path: .trusted sparse-checkout: .github/scripts @@ -120,7 +120,7 @@ jobs: # trusted workflows with other secrets would restore what PR code wrote. - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: false @@ -138,7 +138,7 @@ jobs: - name: Setup EAS if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: # Pinned and installed with npm for the same reasons as mobile-eas-preview.yml. eas-version: 21.8.0 @@ -166,7 +166,7 @@ jobs: - name: Deploy with fingerprint check if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action/continuous-deploy-fingerprint@main + uses: expo/expo-github-action/continuous-deploy-fingerprint@5273c2136bd09c2a341fe3340d3e767ce5a13015 # v9.0.2 env: EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} RUNNER_DEBUG: "1" @@ -249,14 +249,14 @@ jobs: ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a steps: - name: Checkout reviewed commit - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 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 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: path: .trusted sparse-checkout: .github/scripts @@ -288,7 +288,7 @@ jobs: fi - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: package.json @@ -302,13 +302,13 @@ jobs: run: pnpm install --frozen-lockfile --filter=@t3tools/mobile... - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: 17 - name: Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: eas-version: latest eas-cache: false diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 5989d7a0c0cc8..b46e090434f58 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -38,7 +38,7 @@ jobs: - name: Checkout if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 # No sparse-checkout here: it makes actions/checkout fetch with @@ -48,7 +48,7 @@ jobs: - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -66,7 +66,7 @@ jobs: - name: Setup EAS if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: # eas-cli 22 renamed `project` to `app` in build JSON, and # continuous-deploy-fingerprint still reads `build.project.ownerAccount`. @@ -99,7 +99,7 @@ jobs: - name: Deploy with fingerprint check if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action/continuous-deploy-fingerprint@main + uses: expo/expo-github-action/continuous-deploy-fingerprint@5273c2136bd09c2a341fe3340d3e767ce5a13015 # v9.0.2 env: EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} # The Expo action otherwise suppresses the EAS command's failure output. @@ -154,7 +154,7 @@ jobs: - name: Checkout if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 @@ -187,7 +187,7 @@ jobs: - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -205,7 +205,7 @@ jobs: - name: Setup EAS if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: eas-version: latest token: ${{ secrets.EXPO_PREVIEW_TOKEN }} @@ -290,7 +290,7 @@ jobs: ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 @@ -349,7 +349,7 @@ jobs: - name: Mount build cache if: steps.record.outputs.built != 'true' - uses: useblacksmith/stickydisk@v1 + uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2 with: key: ${{ github.repository }}-android-preview-build path: ~/.cache/t3-android-preview @@ -367,7 +367,7 @@ jobs: - name: Setup Node if: steps.record.outputs.built != 'true' - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: package.json @@ -384,14 +384,14 @@ jobs: - name: Setup Java if: steps.record.outputs.built != 'true' - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: 17 - name: Setup EAS if: steps.record.outputs.built != 'true' - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: eas-version: latest token: ${{ secrets.EXPO_PREVIEW_TOKEN }} diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 1bf16d8a8f65e..39f9d01f61348 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -124,7 +124,7 @@ jobs: - id: version_app_token name: Mint release app token for version override if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' && inputs.profile != 'testflight' - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} @@ -132,7 +132,7 @@ jobs: - name: Checkout if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 token: ${{ steps.version_app_token.outputs.token || github.token }} @@ -143,7 +143,7 @@ jobs: - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -161,7 +161,7 @@ jobs: - name: Setup EAS if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: eas-version: latest token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} @@ -498,7 +498,7 @@ jobs: BUILD_TAG: mobile-android-build/${{ needs.production.outputs.android_version }}/${{ needs.production.outputs.android_fingerprint }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.production.outputs.android_sha }} fetch-depth: 0 @@ -551,7 +551,7 @@ jobs: - name: Mount build cache if: steps.record.outputs.built != 'true' - uses: useblacksmith/stickydisk@v1 + uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2 with: key: ${{ github.repository }}-android-store-build path: ~/.cache/t3-android-store @@ -569,7 +569,7 @@ jobs: - name: Setup Node if: steps.record.outputs.built != 'true' - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: package.json @@ -588,14 +588,14 @@ jobs: - name: Setup Java if: steps.record.outputs.built != 'true' - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: 17 - name: Setup EAS if: steps.record.outputs.built != 'true' - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: eas-version: latest token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} @@ -642,7 +642,7 @@ jobs: # 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 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: android-store-aab-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/t3code.aab @@ -704,7 +704,7 @@ jobs: ALLOW_XCODE_RC: ${{ vars.MOBILE_IOS_ALLOW_XCODE_RC == 'true' }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.production.outputs.ios_sha }} fetch-depth: 0 @@ -761,7 +761,7 @@ jobs: - name: Setup Node if: steps.record.outputs.built != 'true' - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: package.json @@ -780,7 +780,7 @@ jobs: - name: Setup EAS if: steps.record.outputs.built != 'true' - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: eas-version: latest token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index 9fabb3a5f2ad2..437f16c4684be 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -33,7 +33,7 @@ jobs: NODE_OPTIONS: --max-old-space-size=8192 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: # The merge ref can advance after the event is queued. Pin its commit # and compare against its first parent, rather than the event's base. @@ -58,7 +58,7 @@ jobs: - name: Setup Vite+ if: steps.changes.outputs.relevant == 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -136,7 +136,7 @@ jobs: printf '%s\n' "$CHANGED_PLATFORMS" > "$RUNNER_TEMP/fingerprint-result/changed-platforms" - name: Upload changed platforms - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fingerprint-result path: ${{ runner.temp }}/fingerprint-result diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml index 45e4c9f2ad42d..a48462eec8c53 100644 --- a/.github/workflows/mobile-fingerprint-label.yml +++ b/.github/workflows/mobile-fingerprint-label.yml @@ -33,7 +33,7 @@ jobs: steps: - id: pr name: Resolve the pull request behind the check - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const run = context.payload.workflow_run; @@ -100,7 +100,7 @@ jobs: - name: Sync native change label if: steps.pr.outputs.pr_number != '' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }} diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 53b0a74a1f5aa..3210ae37d7671 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -51,7 +51,7 @@ jobs: timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -59,7 +59,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -98,7 +98,7 @@ jobs: - name: Upload iOS screenshots if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: app-store-connect-screenshots path: artifacts/app-store/screenshots/apple/ @@ -116,7 +116,7 @@ jobs: T3_SHOWCASE_ANDROID_ABI: x86_64 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -124,7 +124,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -142,13 +142,13 @@ jobs: "$vp_pnpm_bin/pnpm" --version - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: 17 - name: Setup Gradle cache - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - name: Enable KVM run: | @@ -158,7 +158,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: Capture Android showcase - uses: reactivecircus/android-emulator-runner@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: 36 target: google_apis @@ -175,7 +175,7 @@ jobs: - name: Upload Android screenshots if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: google-play-screenshots path: artifacts/app-store/screenshots/google-play/ diff --git a/.github/workflows/pr-limits.yml b/.github/workflows/pr-limits.yml index 75943062735cd..d6a2864f94a9b 100644 --- a/.github/workflows/pr-limits.yml +++ b/.github/workflows/pr-limits.yml @@ -34,13 +34,13 @@ jobs: # branch and never run PR code. Cone mode also checks out the files beside # .github/scripts, which is where TRIAGE_EXEMPTIONS.td and VOUCHED.td live. - name: Checkout trusted scripts - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.repository.default_branch }} sparse-checkout: .github/scripts - name: Close PRs over the limit - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: DRY_RUN: ${{ vars.PR_LIMITS_ENFORCE != 'true' }} with: @@ -57,13 +57,13 @@ jobs: cancel-in-progress: false steps: - name: Checkout trusted scripts - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.repository.default_branch }} sparse-checkout: .github/scripts - name: Request rebases and close what went unanswered - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: DRY_RUN: ${{ vars.PR_LIMITS_ENFORCE != 'true' || (github.event_name == 'workflow_dispatch' && inputs.dry_run) }} with: diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index cb0c9637fcef7..45f5c9b9f7760 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -16,7 +16,7 @@ jobs: steps: - id: config name: Build PR size label config - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: result-encoding: string script: | @@ -64,7 +64,7 @@ jobs: issues: write steps: - name: Ensure PR size labels exist - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} with: @@ -128,11 +128,11 @@ jobs: # git data. Do not add dependency installs, build/test scripts, or cache # actions here; use pull_request plus workflow_run for that pattern instead. - name: Checkout base repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Sync PR size label - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} with: @@ -295,7 +295,7 @@ jobs: ); # Reads the script from the base checkout above; the PR's own commits are only diffed. - name: Report test suite growth - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const { execFileSync } = require("node:child_process"); diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml index 75e2ee8a56548..eb53c07894025 100644 --- a/.github/workflows/pr-vouch.yml +++ b/.github/workflows/pr-vouch.yml @@ -29,7 +29,7 @@ jobs: targets: ${{ steps.collect.outputs.targets }} steps: - id: collect - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | if (context.eventName === "pull_request_target") { @@ -81,7 +81,7 @@ jobs: steps: - id: vouch name: Check PR author trust - uses: mitchellh/vouch/action/check-user@v1 + uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: user: ${{ matrix.target.user }} allow-fail: true @@ -89,7 +89,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Sync PR labels - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_NUMBER: ${{ matrix.target.number }} VOUCH_STATUS: ${{ steps.vouch.outputs.status }} @@ -101,17 +101,17 @@ jobs: { name: "vouch:trusted", color: "1f883d", - description: "PR author is trusted by repo permissions or the VOUCHED list.", + description: "Author is trusted by repo permissions or the VOUCHED list.", }, { name: "vouch:unvouched", color: "fbca04", - description: "PR author is not yet trusted in the VOUCHED list.", + description: "Author is not yet trusted in the VOUCHED list.", }, { name: "vouch:denounced", color: "d1242f", - description: "PR author is explicitly blocked by the VOUCHED list.", + description: "Author is explicitly blocked by the VOUCHED list.", }, ]; diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 4ab6db89ab703..dc88f0150945b 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -48,7 +48,7 @@ jobs: run: pacman -Syu --noconfirm --needed git github-cli jq namcap openssh sudo - name: Checkout packaging sources - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Create unprivileged build user run: | diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml index eed067adfd773..483e3e0b01b7e 100644 --- a/.github/workflows/relay-preview-cleanup.yml +++ b/.github/workflows/relay-preview-cleanup.yml @@ -58,7 +58,7 @@ jobs: ALCHEMY_TELEMETRY_DISABLED: "1" steps: - name: Checkout default branch - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.repository.default_branch }} sparse-checkout: | @@ -67,7 +67,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -170,7 +170,7 @@ jobs: - name: Checkout default branch if: steps.eligible.outputs.eligible == 'yes' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.repository.default_branch }} sparse-checkout: | @@ -180,7 +180,7 @@ jobs: - name: Setup Vite+ if: steps.eligible.outputs.eligible == 'yes' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true diff --git a/.github/workflows/relay-preview.yml b/.github/workflows/relay-preview.yml index fdce22c68a59e..daec319087759 100644 --- a/.github/workflows/relay-preview.yml +++ b/.github/workflows/relay-preview.yml @@ -53,7 +53,7 @@ jobs: fi - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha }} sparse-checkout: | @@ -62,7 +62,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index b227a2b8b12fe..48f410f0c920a 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -157,7 +157,7 @@ jobs: # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata @@ -183,7 +183,7 @@ jobs: - name: Restore staged install cache id: stage_pnpm_cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.stage_pnpm.outputs.dir }} key: ${{ steps.stage_pnpm.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }} @@ -195,7 +195,7 @@ jobs: - name: Cache resource monitor id: resource_monitor_cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }} key: resource-monitor-${{ inputs.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} @@ -203,7 +203,7 @@ jobs: - name: Cache Linux capture helpers if: inputs.platform == 'linux' id: capture_helper_cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot @@ -216,7 +216,7 @@ jobs: # 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 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cache/electron-builder/appimage@*/appimage-tools-runtime-*.tar.gz @@ -234,14 +234,14 @@ jobs: - name: Setup Rust if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: toolchain: stable targets: ${{ inputs.rust_target }} - name: Download relay client tracing config if: inputs.relay_client_tracing - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing @@ -262,14 +262,14 @@ jobs: # ancestor of its paths), so extracting into `apps` restores # apps/server/dist and apps/desktop/dist-electron at their build paths. - name: Download JS bundle - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: js-bundle path: apps - name: Install cargo-xwin if: inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 + uses: taiki-e/install-action@cfdb7ed4ccc7c7dce37759eae89dc5674b469b39 # v2.90.3 with: tool: cargo-xwin@0.23.1 @@ -317,7 +317,7 @@ jobs: - name: Restore target Node.js for the CLI executable id: target_node_cache if: inputs.cli_archive && inputs.platform == 'win' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/node-download key: node-download-v${{ steps.target_node.outputs.version }}-win-${{ inputs.arch }} @@ -344,7 +344,7 @@ jobs: # Saved right away so a later failure in this job still keeps the download. - name: Save target Node.js for the CLI executable if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/node-download key: ${{ steps.target_node_cache.outputs.cache-primary-key }} @@ -479,7 +479,7 @@ jobs: # macOS uploads its archive after "Await CLI notarization" instead. - name: Upload CLI archive if: inputs.cli_archive && inputs.platform != 'mac' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* @@ -559,7 +559,7 @@ jobs: - name: Download Linux CLI archive for WSL if: inputs.platform == 'win' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: cli-linux-${{ inputs.arch }} path: wsl-runtime @@ -680,7 +680,7 @@ jobs: - name: Upload notarized CLI archive if: inputs.cli_archive && inputs.platform == 'mac' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* @@ -700,7 +700,7 @@ jobs: - name: Save staged install cache if: steps.stage_pnpm_changed.outputs.changed == 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.stage_pnpm.outputs.dir }} key: ${{ steps.stage_pnpm_cache.outputs.cache-primary-key }} @@ -731,7 +731,7 @@ jobs: - name: Upload launch check output if: failure() && inputs.launch_check - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: desktop-launch-check-${{ inputs.platform }}-${{ inputs.arch }} path: ${{ runner.temp }}/desktop-launch-check @@ -759,7 +759,7 @@ jobs: - name: Upload verification inputs if: inputs.verify_runner != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: verify-${{ inputs.platform }}-${{ inputs.arch }} path: verify/* @@ -826,7 +826,7 @@ jobs: cp "$source_path" "$target_dir/$binary_name" - name: Upload build artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: desktop-${{ inputs.platform }}-${{ inputs.arch }} path: release-publish/* @@ -835,7 +835,7 @@ jobs: compression-level: 0 - name: Upload resource monitor - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: resource-monitor-${{ inputs.resource_key }} path: resource-monitor-publish/${{ inputs.resource_key }}/* @@ -849,14 +849,14 @@ jobs: timeout-minutes: 15 steps: - name: Download verification inputs - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: verify-${{ inputs.platform }}-${{ inputs.arch }} path: verify - name: Download CLI archive if: inputs.cli_archive - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8827876b3829f..993968245c988 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,9 +48,12 @@ jobs: # triggering commit. Manual stable releases build the commit of the latest # published nightly, so stable only ever ships a build that nightly users # have already run. Scheduled runs also decide here whether a nightly is due. + # The PAUSE_NIGHTLY repository variable skips scheduled runs only; every later + # job needs this one, so a skip here skips the whole run. `update_feed` runs + # without it and checks the variable itself. resolve_commit: name: Resolve release commit - if: github.repository == 'pingdotgg/t3code' + if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'schedule' || vars.PAUSE_NIGHTLY != 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 outputs: @@ -59,13 +62,13 @@ jobs: has_changes: ${{ steps.resolve.outputs.has_changes }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: .github/scripts - id: resolve name: Resolve release commit - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: DISPATCH_CHANNEL: ${{ inputs.channel }} with: @@ -122,7 +125,7 @@ jobs: ref: ${{ needs.resolve_commit.outputs.ref }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.resolve_commit.outputs.ref }} fetch-depth: 0 @@ -163,14 +166,12 @@ jobs: echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "preview" ]]; then - # Manual-only test train: exercises the whole release flow for a - # commit end users must never receive. Never scheduled. + # Manual-only test train: exercises the release flow up to a draft + # release for a commit end users must never receive. Never scheduled. # Same versioning as nightly under its own prerelease identifier. - # A preview release is reachable only by asking for it: npm gets it - # under the `preview` dist-tag, which nothing resolves by default, - # its desktop builds carry no update feed, and no updater manifest - # is attached to the release, so neither stable nor nightly - # installs can ever be offered one. + # A preview release stays a draft (see "Publish prerelease"), its + # desktop builds carry no update feed, no updater manifest is + # attached, and npm gets nothing, so no install can be offered one. nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ @@ -181,7 +182,6 @@ jobs: --github-output echo "release_channel=preview" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" else @@ -226,7 +226,7 @@ jobs: # Share only the verification results, not the large registry metadata cache. - name: Upload dependency verification continue-on-error: true - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata/lockfile-verified.jsonl @@ -239,7 +239,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -271,7 +271,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -311,7 +311,7 @@ jobs: shard: [1, 2] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -330,7 +330,7 @@ jobs: - name: Cache Chromium id: chromium_cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} @@ -370,7 +370,7 @@ jobs: shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -423,7 +423,7 @@ jobs: CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.resolve_commit.outputs.ref }} sparse-checkout: | @@ -463,7 +463,7 @@ jobs: } > "$RUNNER_TEMP/relay-client-tracing.env" - name: Upload relay client tracing config - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing.env @@ -533,7 +533,7 @@ jobs: T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -545,7 +545,7 @@ jobs: # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification continue-on-error: true - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata @@ -558,7 +558,7 @@ jobs: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - name: Download relay client tracing config - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing @@ -604,7 +604,7 @@ jobs: # Two paths under apps/ so the artifact root is apps/; consumers download # into `apps` to restore both at their original locations. - name: Upload JS bundle - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: js-bundle path: | @@ -622,7 +622,7 @@ jobs: # 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 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: npm-launcher path: npm-packages/t3.tgz @@ -817,7 +817,7 @@ jobs: pull-requests: read steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} # Release notes need history back to the previous tag, not file contents. @@ -852,14 +852,14 @@ jobs: fi - name: Download all desktop artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: desktop-* merge-multiple: true path: release-assets - name: Download all CLI archives - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: cli-* merge-multiple: true @@ -1002,7 +1002,7 @@ jobs: - name: Publish release if: needs.preflight.outputs.previous_tag != '' - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -1019,7 +1019,7 @@ jobs: - name: Publish first release if: needs.preflight.outputs.previous_tag == '' - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -1036,22 +1036,93 @@ jobs: # The action keeps a stable release a draft until its assets finish uploading, but publishes a # prerelease first. Desktop clients update connected servers from the CLI archives as soon as # they see the release, so a prerelease also goes public only once every asset is attached. + # Previews stay drafts, because a draft has no git tag. releases.atom lists the 10 newest tags, + # a day's `preview` tags can sort above its `nightly` ones, and older updaters find nightlies only there. - name: Publish prerelease - if: needs.preflight.outputs.is_prerelease == 'true' + if: needs.preflight.outputs.is_prerelease == 'true' && needs.preflight.outputs.release_channel != 'preview' env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ needs.preflight.outputs.tag }} run: gh release edit "$RELEASE_TAG" --draft=false + # A nightly install finds updates in releases.atom, which holds the 10 newest git tags of any + # kind, so a burst of other tags hides every nightly. The `update-feed` release is the fixed URL + # newer installs also read. Every unpaused scheduled tick and every nightly repairs it, then fails when + # the feed's first nightly is not the newest one. See docs/operations/release.md#update-feed. + update_feed: + name: Check the update feed + needs: [preflight, release] + if: ${{ !cancelled() && github.repository == 'pingdotgg/t3code' && ((github.event_name == 'schedule' && vars.PAUSE_NIGHTLY != 'true') || needs.preflight.outputs.release_channel == 'nightly') }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + sparse-checkout: .github/scripts + + - id: feed + name: Read the update feed + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + retries: 3 + script: | + const { readNightlyUpdateFeed } = require('./.github/scripts/check-nightly-release.cjs'); + const { tag, pointerTag, feedNightly, feedTags } = await readNightlyUpdateFeed({ github, context }); + if (!tag) return; + core.info(`Newest nightly: ${tag}. Pointer: ${pointerTag}. Feed: ${feedTags?.join(', ')}.`); + core.setOutput('tag', tag); + core.setOutput('pointer_stale', pointerTag !== tag); + core.setOutput('feed', feedTags === undefined ? 'unreadable' : feedNightly === tag ? 'ok' : 'hidden'); + + - name: Point the update feed at the newest nightly + if: steps.feed.outputs.pointer_stale == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ steps.feed.outputs.tag }} + run: | + gh api "repos/$GH_REPO/releases/tags/$RELEASE_TAG" --jq '{tag_name, name, body}' > nightly.json + gh release view update-feed > /dev/null 2>&1 || + gh release create update-feed --prerelease --latest=false --title "Update feed" \ + --notes "The desktop updater reads this release to find the newest nightly. Do not delete it. See docs/operations/release.md." + gh release upload update-feed nightly.json --clobber + + # Every run, because the pointer can be current while the release's flags are wrong. A draft's + # files are not public, and a release marked "latest" is the one stable installs update to. + - name: Keep the update feed release public and never latest + if: steps.feed.outputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release edit update-feed --draft=false --prerelease --latest=false + + # An unreadable feed says nothing about its tags and the next tick reads it again, so only a + # feed that was read and names another nightly first fails the run. + - name: Fail when the feed hides the newest nightly + if: steps.feed.outputs.tag != '' && steps.feed.outputs.feed != 'ok' + env: + RELEASE_TAG: ${{ steps.feed.outputs.tag }} + FEED: ${{ steps.feed.outputs.feed }} + run: | + if [[ "$FEED" == "unreadable" ]]; then + echo "::warning::releases.atom could not be read, so this run did not check it." + exit 0 + fi + echo "::error::The first nightly in releases.atom is not $RELEASE_TAG. Nightly installs older than 0.1.10-nightly.20261112.4603 cannot reach it, and neither can later builds that do not read the pointer when the feed names an older nightly. A preview from a branch not rebased onto main is the usual cause. See docs/operations/release.md#update-feed." + exit 1 + # The `t3` npm package is a launcher that downloads this version's CLI # archive from the GitHub Release, so it publishes only once that release - # exists. Preview publishes too, under the `preview` dist-tag, which - # nothing resolves unless asked for by name. + # exists. A preview release stays a draft, whose files are not public, so + # a preview never publishes the launcher. publish_cli: name: Publish CLI to npm needs: [preflight, build_bundle, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 permissions: @@ -1060,7 +1131,7 @@ jobs: steps: # Only what setup-ci-deps reads to install the project's Node and npm. - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -1087,7 +1158,7 @@ jobs: fi - name: Download the npm launcher - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.build_bundle.outputs.npm_launcher_artifact_id }} path: npm-packages @@ -1149,7 +1220,7 @@ jobs: VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -1163,7 +1234,7 @@ jobs: install-args: --filter=@t3tools/scripts... --filter=@t3tools/web... - name: Download relay client tracing config - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing @@ -1249,7 +1320,7 @@ jobs: # 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 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -1320,7 +1391,7 @@ jobs: VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -1386,7 +1457,7 @@ jobs: VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -1424,14 +1495,14 @@ jobs: steps: - id: app_token name: Mint release app token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: main fetch-depth: 0 @@ -1515,7 +1586,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | diff --git a/.github/workflows/stale-needs-more-info.yml b/.github/workflows/stale-needs-more-info.yml index b7b475a6117dc..9d994115705f4 100644 --- a/.github/workflows/stale-needs-more-info.yml +++ b/.github/workflows/stale-needs-more-info.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 with: only-issue-labels: "needs more info" # The label is applied by the triage flow, so never auto-mark; close 7 days after it was added. diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml index 23eec72923bd8..94987b447745b 100644 --- a/.github/workflows/thread-transfer-report.yml +++ b/.github/workflows/thread-transfer-report.yml @@ -22,7 +22,7 @@ jobs: # workflow_run has a write-capable token even for fork PRs. Only load the # publisher from the trusted default branch and never execute PR code. - name: Checkout trusted publisher - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.repository.default_branch }} sparse-checkout: .github/scripts @@ -32,7 +32,7 @@ jobs: - id: resolve name: Resolve PR and baseline artifacts - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const reporter = require("./.github/scripts/thread-transfer-report.cjs"); @@ -40,7 +40,7 @@ jobs: - name: Download PR result if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: thread-transfer-results path: ${{ runner.temp }}/thread-transfer/pr @@ -49,7 +49,7 @@ jobs: - name: Download main baseline if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: thread-transfer-results path: ${{ runner.temp }}/thread-transfer/main @@ -58,7 +58,7 @@ jobs: - name: Update thread transfer comment if: steps.resolve.outputs.publish == 'true' - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_NUMBER: ${{ steps.resolve.outputs.pull_number }} PR_SHA: ${{ steps.resolve.outputs.pr_sha }} diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index 4b3f8952d503d..69db6b6025e8f 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -46,7 +46,7 @@ jobs: VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha }} sparse-checkout: | @@ -55,7 +55,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -97,7 +97,7 @@ jobs: echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" - name: Comment deployment URL - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment_url }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index 3a70ad5a26a05..9a8e69d69cc29 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -30,7 +30,7 @@ jobs: timeout-minutes: 45 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -40,7 +40,7 @@ jobs: # setup-vp's own cache restores a Linux-shaped store on Windows, which is # slower than no cache (see #7975). Cache pnpm's Windows store directly. - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: false @@ -52,7 +52,7 @@ jobs: run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' - name: Cache packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.package_cache_path.outputs.path }} key: windows-tests-packages-v1-${{ hashFiles('pnpm-lock.yaml') }} diff --git a/.gitignore b/.gitignore index d8ea7d732cbdc..d4fc06b22a439 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ node_modules/ .vscode/* !.vscode/extensions.json !.vscode/settings.json +/packages/ssh/.ssh-launcher-test-*/ diff --git a/AGENTS.md b/AGENTS.md index 38753f94383ce..ec33fb7280b61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # T3 Code -T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro) and serves web, desktop, and mobile clients. +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro, Pi, Muse Code, fx) and serves web, desktop, and mobile clients. You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste - **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. - **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` -- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, and Kiro each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, Kiro, Pi, Muse Code, fx, and ACP Registry each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". - **Agents.** A capability a user can trigger is usually one an agent should reach through MCP tools, and scheduled tasks run the same paths. That only works when it is a service method, not handler code. - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. @@ -83,7 +83,8 @@ The most common defect in this repo is a change that works on the path you teste - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. - Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings. - To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential). -- Stop what you started, by the PID you tracked. See rule 1. +- Stop what you started, by the PID you tracked. See rule 1. `vp` does not pass SIGTERM on, so signalling the `vp run dev` PID alone leaves Vite and the server running. Stop the background task with your harness's own stop, which ends the whole tree. +- Run dev servers, Metro, and builds from your own shell so they end with your session. Do not detach them with `systemd-run`, `nohup`, `setsid`, or `tmux` unless the user asks for one that outlives the session. Detached servers ran for weeks and filled RAM and swap. The dev runner stops itself above `--memory-limit-gb` (half of RAM, at most 16 GiB) on Linux. - Need a second checkout? `git worktree add` it next to this worktree, not in `/tmp`. Remove it when done. ## Test data @@ -149,7 +150,7 @@ For mobile verification, a missing or outdated native client is an install step, ## Pull requests - Never make a PR unless the developer explicitly asks you to do so. -- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): push to `origin`, open and track PRs with `cafe` and their git.cafe URLs, and keep `gh` for merging. +- `origin` is not always GitHub. Check `git remote get-url origin` before your first forge command and use that forge's CLI and skill. See [Forge mirror](docs/operations/development.md#forge-mirror-optional). - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. - UI changes need before/after images. Motion or timing needs a short video. diff --git a/README.md b/README.md index 0ed38d677b630..378022bc9b857 100644 --- a/README.md +++ b/README.md @@ -1,157 +1,112 @@ -# T3 Code - > [!NOTE] > This is a fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3code). > [Fork differences](./docs/fork-differences.md) lists what this fork adds or changes. -T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). +

+ + T3 Code logo + +

-Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, and fx. If they're set up on your computer, T3 Code can control them. +

T3 Code

-## "Wait, what are you selling me?" +

The open-source control plane for coding agents.

-Nothing. We built T3 Code because we wanted the best possible development experience with agents. We were inspired by existing solutions like the Codex desktop app, Conductor, Claude Desktop and Cursor Glass, but none met our bar. +

+ Download · + iOS · + Android · + Web · + Docs · + Discord +

-We wanted something performant, remote-ready, and truly open. If we ever go the wrong direction, we want you to have everything you need to fork and build the editor that you want. +

+ + T3 Code on desktop with a finished Claude thread, next to the phone app asking to approve a command from Codex + +

-## Installation +T3 Code runs the coding agents on your machine and gives them one fast interface. Start a thread at your desk, then approve the next step from your phone. -> [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, Antigravity, and fx. Install and authenticate at least one provider before use: -> -> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` -> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` -> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` -> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` -> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` -> - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. -> - fx: install [fx](https://fx.sh) and run `fx login` on the machine running the T3 Code server ([setup guide](./docs/user/providers-fx.md)) +It works with Claude Code, Codex, Cursor, OpenCode, Grok Build, Antigravity, Kiro, Pi, Muse Code, fx, and any [ACP](./docs/user/providers-acp.md) agent. If an agent is set up on your computer, T3 Code can control it. -### Command line +## What you get -```bash -curl -fsSL https://t3.codes/install.sh | sh -``` +- **Your subscriptions.** T3 Code does not resell tokens. It drives the agents you are already signed in to. +- **Every agent in one place.** Change models in the middle of a thread. Let Claude hand a task to Codex. +- **Desktop, web, and phone.** One server, and every client shows the same threads. There are apps for iOS and Android. +- **Remote ready.** Run agents on any machine. Connect over your local network, Tailscale, or [T3 Connect](./docs/user/remote-access.md). +- **Git built in.** Each thread can work in its own worktree. Review the diff, then commit, push, and open a pull request with one button. +- **Fast.** Performance is a feature here. We measure it, and we treat a dropped frame as a bug. +- **Open.** MIT licensed. If you don't like something, fork it. -On Windows, in PowerShell: - -```powershell -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`. It offers to install the `t3` command for you. +## Install ### Desktop app -Install the latest version of the desktop app from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), or from your favorite package registry: +[Download T3 Code](https://t3.codes/download) for macOS, Windows, or Linux. You can also use a package manager: -#### Windows (`winget`) +| Platform | Command | +| -------------- | -------------------------------------------------------------------- | +| macOS | `brew install --cask t3-code` | +| Windows | `winget install T3Tools.T3Code` or `scoop install extras/t3code` | +| Arch Linux | `yay -S t3code-bin` | +| Nix | `nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code` | +| Debian, Ubuntu | `.deb` from [Releases](https://github.com/pingdotgg/t3code/releases) | -```bash -winget install T3Tools.T3Code -``` - -#### Windows (`scoop`) - -```bash -scoop install extras/t3code -``` - -#### macOS (Homebrew) - -```bash -brew install --cask t3-code -``` - -#### Debian, Ubuntu (`.deb`) +### Command line -Download the `.deb` from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), then: +This installs the `t3` command. Run `t3` to start the server and open the web app. ```bash -sudo apt install ./T3-Code-*.deb +curl -fsSL https://t3.codes/install.sh | sh ``` -#### Arch Linux (AUR) - -Stable: +On Windows, in PowerShell: -```bash -yay -S t3code-bin +```powershell +irm https://t3.codes/install.ps1 | iex ``` -Nightly: +To try it once without an install, run `npx t3@latest`. -```bash -yay -S t3code-nightly-bin -``` +### Phone -The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur). +Get the app from the [App Store](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824) or [Google Play](https://play.google.com/store/apps/details?id=com.t3tools.t3code). Then follow [remote access](./docs/user/remote-access.md) to connect it to your computer. -#### Nix (nixpkgs) +### Sign in to an agent -```bash -nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code -``` +T3 Code needs one agent that is installed and signed in. For example, install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`, or install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`. -The package is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code) -and includes the desktop app (`t3code-desktop`) and the `t3` command. -See [Nix installation](./docs/user/install.md#nix-nixpkgs) for NixOS configuration and updates. +[Provider setup](./docs/user/install.md#providers) has the steps for each agent. [Install T3 Code](./docs/user/install.md) covers nightly builds, AppImage, WSL, and NixOS. -## Some notes +## "Wait, what are you selling me?" -We are very very early in this project. Expect bugs. +Nothing. We built T3 Code because we wanted the best possible development experience with agents. We were inspired by existing solutions like the Codex desktop app, Conductor, Claude Desktop and Cursor Glass, but none met our bar. -We are (mostly) not accepting contributions yet. Small fixes may be considered. Big features will not be. +We wanted something performant, remote-ready, and truly open. If we ever go the wrong direction, we want you to have everything you need to fork and build the tool that you want. ## Documentation -Full docs live in [docs/](./docs). There's no docs site yet. - -- [FAQ](./docs/user/faq.md) - [Install and first run](./docs/user/install.md) -- [Permission modes](./docs/user/permission-modes.md) -- [Keyboard shortcuts](./docs/user/keybindings.md) -- [Project settings](./docs/user/project-settings.md) -- [Appearance preferences](./docs/user/appearance.md) - [Remote access from a phone or another machine](./docs/user/remote-access.md) -- [Connect Claude Code, Codex, ChatGPT and other agents over MCP](./docs/user/outside-agents.md) -- [Keeping app and server in sync](./docs/user/updating.md) -- [Source control integrations](./docs/user/source-control.md) -- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) -- [Run T3 Code as a background service](./docs/user/background-service.md) - -Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md). - -## If you REALLY want to contribute still.... read this first - -### Install `vp` - -T3 Code uses Vite+ so you'll need to install the global `vp` command-line tool. - -#### macOS / Linux - -```bash -curl -fsSL https://vite.plus | bash -``` - -#### Windows - -```bash -irm https://vite.plus/ps1 | iex -``` +- [Permission modes](./docs/user/permission-modes.md) +- [Source control](./docs/user/source-control.md) +- [Connect outside agents over MCP](./docs/user/outside-agents.md) +- [FAQ](./docs/user/faq.md) -Checkout their getting started guide for more information: https://viteplus.dev/guide/ +[All docs](./docs/README.md) has the full list. -### Install dependencies +## Contributing -```bash -vp i -``` +We have limited review capacity. Small, focused fixes are the most likely to be merged. Features need an approved discussion first. -Read [CONTRIBUTING.md](./CONTRIBUTING.md) before reporting a bug or opening a PR. +- Read [CONTRIBUTING.md](./CONTRIBUTING.md) before you report a bug or open a pull request. +- Put feature requests in [Ideas](https://github.com/pingdotgg/t3code/discussions/categories/ideas). +- Build from source with the [development runbook](./docs/operations/development.md#first-checkout). The [architecture overview](./docs/internals/overview.md) explains how it works. +- Get help in [Discord](https://discord.gg/jn4EGJjrvv). -Have a feature request? Start an [Ideas discussion](https://github.com/pingdotgg/t3code/discussions/categories/ideas). +## License -Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). +[MIT](./LICENSE) diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 184843d480249..faef2aeaa8ce1 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -426,7 +426,7 @@ it.effect.each(["startup", "open-url", "early-second-instance"] as const)( ), Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), ); - }).pipe(Effect.scoped), + }), ); it.effect("hands web and app links to the renderer and leaves other links alone", () => @@ -483,5 +483,5 @@ it.effect("hands web and app links to the renderer and leaves other links alone" }), ), ); - }).pipe(Effect.scoped), + }), ); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 2dc59c58899ee..dd61b7984ec1f 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -160,7 +160,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { 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", () => @@ -202,7 +202,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect((yield* Effect.flip(command.install)).message).toContain("Could not set up"); yield* command.uninstall; expect(yield* fs.readFileString(launcher)).toBe("npm's t3\n"); - }).pipe(Effect.scoped), + }), ); it.effect("finds and removes a link left by a previous T3 home", () => @@ -259,7 +259,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { ); 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", () => @@ -309,7 +309,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { installedPath: path.join(home, ".local", "bin", "t3"), }); expect(installed.shadowedBy).toBeUndefined(); - }).pipe(Effect.scoped), + }), ); it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( @@ -343,7 +343,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { onPath: true, installedPath: path.join(home, ".local", "bin", "t3"), }); - }).pipe(Effect.scoped), + }), ); it.effect("does not treat its own launcher as another t3 when its folder is first on PATH", () => @@ -372,7 +372,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(yield* command.state).toEqual(removed); expect(yield* fs.exists(installed.installedPath!)).toBe(false); expect(yield* fs.exists(launcher)).toBe(true); - }).pipe(Effect.scoped), + }), ); it.effect("leaves the Windows PATH alone when it cannot be read", () => @@ -411,7 +411,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const reader = yield* commandIn({ home, platform: "win32" }, inherited.spawner); yield* reader.install; expect(inherited.registry).toMatchObject({ path: `${userPath};${launcherDir}`, writes: 1 }); - }).pipe(Effect.scoped), + }), ); it.effect("does not claim or remove a Windows PATH entry the user added", () => @@ -441,6 +441,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { 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/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index e2f97c7d2ff71..d43e7a8388b83 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -39,7 +39,7 @@ const run = (shim: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = }); const REPORTER = - '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n'; + '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME appimage=$APPIMAGE"\nprintf "%s\\n" "$@"\nexit 3\n'; const fakeAppImage = (canMount = true, app = REPORTER) => { const root = tempRoot(); @@ -150,6 +150,14 @@ describe("renderCliShim", () => { ); }); + // The server skips its compile cache only under APPIMAGE; each mount path is new. + it("tells the CLI it runs from the mounted AppImage", () => { + const { appImage, shim } = fakeAppImage(); + const result = run(shim, ["--version"]); + expect(result.status).toBe(3); + expect(result.stdout).toContain(`appimage=${appImage}`); + }); + it("keeps the install's T3 home under sudo, unless one is set", () => { const root = tempRoot(); writeExecutable(NodePath.join(root, "app"), REPORTER); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index eba61f561f526..217f3b327dc9e 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -109,6 +109,8 @@ export const renderCliShim = (input: { ' (cd "$work" && "$app" --appimage-extract >/dev/null) || { echo "Could not open $app." >&2; exit 1; }', ' mount="$work/squashfs-root"', "fi", + // The runtime sets this when it launches the app; the CLI skips its path-keyed compile cache on it. + 'export APPIMAGE="$app"', run("$mount"), "", ].join("\n"); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 98c75385afc51..5dfde9103f152 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -99,7 +99,7 @@ const withStore = ( prefix: "t3-desktop-connection-catalog-test-", }); return yield* effect.pipe(Effect.provide(layerFor(baseDir, encryptionAvailable))); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + }).pipe(Effect.provide(NodeServices.layer)); describe("DesktopConnectionCatalogStore", () => { it.effect("persists and reads an encrypted connection catalog", () => @@ -298,6 +298,6 @@ describe("DesktopConnectionCatalogStore", () => { assert.notEqual(error.message, decryptError.message); yield* Ref.set(failDecrypt, false); assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/app/DesktopExitChildren.test.ts b/apps/desktop/src/app/DesktopExitChildren.test.ts new file mode 100644 index 0000000000000..800a0f5bd4c0f --- /dev/null +++ b/apps/desktop/src/app/DesktopExitChildren.test.ts @@ -0,0 +1,62 @@ +// @effect-diagnostics nodeBuiltinImport:off - Spawns a real process tree for /proc. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import { assert, describe, it } from "@effect/vitest"; + +import { stopChildProcessesBeforeExit, stopZygotesBeforeExit } from "./DesktopExitChildren.ts"; + +const isRunning = (pid: number) => { + try { + const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8"); + return !"ZX".includes(stat.charAt(stat.lastIndexOf(")") + 2)); + } catch { + return false; + } +}; + +describe.runIf(NodeFS.existsSync("/proc/self/task"))("stopChildProcessesBeforeExit", () => { + it("terminates every child of the given process before returning", async () => { + const parent = NodeChildProcess.spawn( + "sh", + ["-c", "sleep 60 & echo $!; sleep 60 & echo $!; wait"], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + try { + const childPids = await new Promise>((resolve) => { + let output = ""; + parent.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + const pids = output.split("\n").filter(Boolean).map(Number); + if (pids.length === 2) resolve(pids); + }); + }); + assert.isTrue(childPids.every(isRunning)); + + assert.deepStrictEqual(stopChildProcessesBeforeExit(parent.pid!), []); + + assert.isFalse(childPids.some(isRunning)); + } finally { + parent.kill("SIGKILL"); + } + }); + + it("kills zygote children before returning and leaves other helpers running", async () => { + const spawnTitled = async (title: string) => { + const child = NodeChildProcess.spawn("sleep", ["60"], { argv0: title, stdio: "ignore" }); + await new Promise((resolve) => child.once("spawn", resolve)); + return child; + }; + // Chromium rewrites a zygote's argv into one space-separated title. + const zygote = await spawnTitled("/opt/T3 Code/t3code --type=zygote --no-sandbox"); + const utility = await spawnTitled("/opt/T3 Code/t3code --type=utility"); + try { + stopZygotesBeforeExit(process.pid); + + assert.isFalse(isRunning(zygote.pid!)); + assert.isTrue(isRunning(utility.pid!)); + } finally { + zygote.kill("SIGKILL"); + utility.kill("SIGKILL"); + } + }); +}); diff --git a/apps/desktop/src/app/DesktopExitChildren.ts b/apps/desktop/src/app/DesktopExitChildren.ts new file mode 100644 index 0000000000000..9426c1b4ff884 --- /dev/null +++ b/apps/desktop/src/app/DesktopExitChildren.ts @@ -0,0 +1,87 @@ +// @effect-diagnostics nodeBuiltinImport:off - Runs synchronously in the process "exit" event, where nothing async completes. +import * as NodeFS from "node:fs"; + +// Matches the backend's quit budget: SIGTERM, 5 s grace, SIGKILL, 1 s wait. +const TERMINATE_GRACE_MS = 5_000; +const KILL_WAIT_MS = 1_000; + +const readProc = (path: string): string | undefined => { + try { + return NodeFS.readFileSync(path, "utf8"); + } catch { + return undefined; + } +}; + +// Chromium launches helpers from its own threads, so each thread's children file is read. +const childPids = (pid: number): ReadonlyArray => { + let tasks: ReadonlyArray; + try { + tasks = NodeFS.readdirSync(`/proc/${pid}/task`); + } catch { + return []; + } + return tasks.flatMap((task) => + (readProc(`/proc/${pid}/task/${task}/children`) ?? "") + .split(" ") + .filter((value) => value.trim() !== "") + .map(Number), + ); +}; + +// A zygote rewrites its argv into one space-separated title. +const isZygote = (pid: number): boolean => + (readProc(`/proc/${pid}/cmdline`) ?? "") + .replaceAll("\0", " ") + .split(" ") + .includes("--type=zygote"); + +// A zombie has already released its memory mappings. +const isRunning = (pid: number): boolean => { + const stat = readProc(`/proc/${pid}/stat`); + if (stat === undefined) return false; + const state = stat.charAt(stat.lastIndexOf(")") + 2); + return state !== "Z" && state !== "X"; +}; + +const signal = (pids: ReadonlyArray, name: NodeJS.Signals) => { + for (const pid of pids) { + try { + process.kill(pid, name); + } catch {} + } +}; + +const waitUntilStopped = (pids: ReadonlyArray, maxMs: number): ReadonlyArray => { + const tick = new Int32Array(new SharedArrayBuffer(4)); + let running = pids.filter(isRunning); + for (let waited = 0; waited < maxMs && running.length > 0; waited += 10) { + Atomics.wait(tick, 0, 0, 10); + running = running.filter(isRunning); + } + return running; +}; + +/** + * Stops the main process's children while it is still alive. On an AppImage the image unmounts + * as soon as main exits, so children left running then die with SIGBUS. Linux only (/proc). + * Returns the pids that needed SIGKILL. + */ +export const stopChildProcessesBeforeExit = (pid: number): ReadonlyArray => { + const children = childPids(pid); + signal(children, "SIGTERM"); + const survivors = waitUntilStopped(children, TERMINATE_GRACE_MS); + signal(survivors, "SIGKILL"); + waitUntilStopped(survivors, KILL_WAIT_MS); + return survivors; +}; + +/** + * Chromium's zygotes exit only after main does, so on an AppImage they outlive the mount even on + * an orderly quit. Kills them while main still holds the image mounted. Linux only (/proc). + */ +export const stopZygotesBeforeExit = (pid: number): void => { + const zygotes = childPids(pid).filter(isZygote); + signal(zygotes, "SIGKILL"); + waitUntilStopped(zygotes, KILL_WAIT_MS); +}; diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 30df96c58ad79..e11e946dd41ba 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; @@ -117,6 +118,7 @@ describe("DesktopLifecycle", () => { const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { platform, isDevelopment: false, + appImagePath: Option.none(), } as DesktopEnvironment.DesktopEnvironment["Service"]); const layer = DesktopLifecycle.layer.pipe( diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 0b5039bd623df..9568e72dde310 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -3,6 +3,7 @@ import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -12,6 +13,7 @@ import type * as Electron from "electron"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { stopChildProcessesBeforeExit, stopZygotesBeforeExit } from "./DesktopExitChildren.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -69,15 +71,18 @@ function addScopedListener>( target: unknown, eventName: string, listener: (...args: Args) => void, + options: { readonly prepend?: boolean } = {}, ): Effect.Effect { const eventTarget = target as { on: (eventName: string, listener: (...args: Array) => void) => unknown; + prependListener: (eventName: string, listener: (...args: Array) => void) => unknown; removeListener: (eventName: string, listener: (...args: Array) => void) => unknown; }; const untypedListener = listener as unknown as (...args: Array) => void; return Effect.acquireRelease( Effect.sync(() => { - eventTarget.on(eventName, untypedListener); + if (options.prepend) eventTarget.prependListener(eventName, untypedListener); + else eventTarget.on(eventName, untypedListener); }), () => Effect.sync(() => { @@ -300,6 +305,34 @@ export const make = DesktopLifecycle.of({ ); }); + // Electron can exit without before-quit, e.g. when the X11/Wayland connection drops. + // Children (the backend too) get SIGTERM while main still holds an AppImage mount open. + // Prepended so the record is buffered before the trace sink's own exit flush runs. + yield* addScopedListener( + process, + "exit", + (exitCode: number) => { + Effect.runSyncWith(context)( + Effect.gen(function* () { + const shutdown = yield* DesktopShutdown.DesktopShutdown; + // "allowed" without a finished shutdown is the updater's quit; its relaunched app is a child. + if (quit.phase === "allowed" || (yield* shutdown.isComplete)) return; + const attributes = { exitCode, quitPhase: quit.phase }; + yield* Effect.gen(function* () { + yield* logLifecycleError( + "desktop main process exiting before shutdown completed", + attributes, + ); + if (environment.platform !== "linux") return; + const killed = stopChildProcessesBeforeExit(process.pid); + yield* Effect.annotateCurrentSpan({ killedChildPids: killed }); + }).pipe(Effect.withSpan("desktop.lifecycle.exit", { attributes })); + }), + ); + }, + { prepend: true }, + ); + if (environment.platform !== "win32") { yield* addScopedListener(process, "SIGINT", () => { quitFromSignal("SIGINT", runEffect, quit); @@ -308,6 +341,13 @@ export const make = DesktopLifecycle.of({ quitFromSignal("SIGTERM", runEffect, quit); }); } + + // Unscoped: the app scope closes before Electron exits, and every exit path must run this. + if (environment.platform === "linux" && Option.isSome(environment.appImagePath)) { + yield* Effect.sync(() => { + process.once("exit", () => stopZygotesBeforeExit(process.pid)); + }); + } }).pipe(Effect.withSpan("desktop.lifecycle.register")), }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 46c0544edcb21..d8766ea4fb209 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -616,7 +616,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { } assert.isFalse(yield* fileSystem.exists(fixture.cachePath)); assert.equal(iconCacheRuns(run), 0); - }).pipe(Effect.scoped), + }), ); it.effect("replaces same-length stale icons and refreshes an existing cache once", () => @@ -654,7 +654,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { run.commands.filter(({ command }) => command === "gtk-update-icon-cache"), [{ command: "gtk-update-icon-cache", args: ["-f", "-t", fixture.hicolorDir] }], ); - }).pipe(Effect.scoped), + }), ); it.effect("refreshes when this launch changed an icon even if the cache looks newer", () => @@ -675,7 +675,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { assert.isTrue(yield* fileSystem.exists(path.join(fixture.appsDir, "com.t3tools.T3Code.png"))); assert.equal(iconCacheRuns(run), 1); - }).pipe(Effect.scoped), + }), ); it.effect("retries a failed icon cache refresh on the next launch", () => @@ -698,7 +698,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { yield* registerWithRealFileSystem(recovering); yield* registerWithRealFileSystem(recovering); assert.equal(iconCacheRuns(recovering), 1); - }).pipe(Effect.scoped), + }), ); it.effect("still registers the handler when the theme icon install fails", () => @@ -722,6 +722,6 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { missingTool.commands.map(({ command }) => command), ["gtk-update-icon-cache", "update-desktop-database", "xdg-mime"], ); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index ab8ea0b8e9d6e..ceb29b149e3d6 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -281,6 +281,27 @@ describe("DesktopObservability", () => { ), ); + it.effect("startup prunes only canonical rotation suffixes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-rotating-prune-test-", + }); + const filePath = `${directory}/server-child.log`; + const retained = ["1", "3", "04", "4.0", "4e0", "0x4", "+4", "notes"]; + for (const suffix of [...retained, "4", "50"]) { + yield* fileSystem.writeFileString(`${filePath}.${suffix}`, suffix); + } + + yield* DesktopObservability.makeRotatingLogFileWriter({ filePath, maxFiles: 3 }); + + assert.deepEqual( + (yield* fileSystem.readDirectory(directory)).sort(), + retained.map((suffix) => `server-child.log.${suffix}`).sort(), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("stops every export when the OpenTelemetry SDK is disabled", () => { const requests: Array = []; return Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 3cd3936a59bfc..16192fff46de8 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -263,8 +263,10 @@ export const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")( const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); for (const entry of entries) { if (!entry.startsWith(`${baseName}.`)) continue; - const suffix = Number(entry.slice(baseName.length + 1)); - if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; + const suffixText = entry.slice(baseName.length + 1); + const suffix = Number(suffixText); + if (!Number.isInteger(suffix) || String(suffix) !== suffixText || suffix <= maxFiles) + continue; yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); } }); @@ -654,6 +656,8 @@ const layerTelemetry = Layer.unwrap( maxFiles: DESKTOP_LOG_FILE_MAX_FILES, batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, }); + // Unscoped: Electron can exit without closing the app scope, e.g. when the display drops. + process.once("exit", sink.flushSync); const delegate = endpoints.traces === undefined ? undefined diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 3126d12a2e938..8a8389082ce89 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -5,6 +5,11 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, DesktopTelemetryControlMessage, } from "@t3tools/contracts"; +import { + DATABASE_MIGRATION_PROGRESS_MESSAGE, + DATABASE_MIGRATIONS_FINISHED_MESSAGE, + DATABASE_MIGRATIONS_STARTED_MESSAGE, +} from "@t3tools/shared/databaseMigrationLog"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -194,129 +199,125 @@ function makeTestInstance(input: MakeInstanceInput) { describe("DesktopBackendManager", () => { it.effect("notifies readiness waiters on HTTP readiness and stop without clock advancement", () => - Effect.scoped( - Effect.gen(function* () { - const probe = yield* Deferred.make(); - const ready = yield* Deferred.make(); - const exit = yield* Deferred.make(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.acquireRelease( - Effect.succeed( - makeProcess({ - exitCode: Deferred.await(exit), - kill: () => - Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)).pipe(Effect.asVoid), - }), - ), - () => Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)), + Effect.gen(function* () { + const probe = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const exit = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.acquireRelease( + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(exit), + kill: () => + Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)).pipe(Effect.asVoid), + }), ), + () => Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)), ), ), - httpClientLayer: Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Deferred.succeed(probe, undefined).pipe( - Effect.andThen(Deferred.await(ready)), - Effect.as(HttpClientResponse.fromWeb(request, new Response("{}"))), - ), + ), + httpClientLayer: Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Deferred.succeed(probe, undefined).pipe( + Effect.andThen(Deferred.await(ready)), + Effect.as(HttpClientResponse.fromWeb(request, new Response("{}"))), ), ), - }); - yield* instance.start; - yield* Deferred.await(probe); - const waiter = yield* instance.waitForReady(Duration.seconds(15)).pipe(Effect.forkChild); - yield* Deferred.succeed(ready, undefined); - assert.isTrue(yield* Fiber.join(waiter)); - yield* instance.stop(); - assert.isFalse(yield* instance.waitForReady(Duration.seconds(15))); - }), - ), + ), + }); + yield* instance.start; + yield* Deferred.await(probe); + const waiter = yield* instance.waitForReady(Duration.seconds(15)).pipe(Effect.forkChild); + yield* Deferred.succeed(ready, undefined); + assert.isTrue(yield* Fiber.join(waiter)); + yield* instance.stop(); + assert.isFalse(yield* instance.waitForReady(Duration.seconds(15))); + }), ); it.effect("spawns the backend with fd3 bootstrap and fd4 telemetry", () => - Effect.scoped( - Effect.gen(function* () { - let spawnedCommand: ChildProcess.Command | undefined; - let bootstrapJson = ""; - let telemetryJson = ""; - let readyCount = 0; - const ready = yield* Deferred.make(); - const exited = yield* Queue.unbounded(); - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - spawnedCommand = command; - if (command._tag === "StandardCommand") { - const fd3 = command.options.additionalFds?.fd3; - if (fd3?.type === "input" && fd3.stream) { - bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); - } - const fd4 = command.options.additionalFds?.fd4; - if (fd4?.type === "input" && fd4.stream) { - telemetryJson = yield* fd4.stream.pipe(Stream.decodeText(), Stream.mkString); - } + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + let bootstrapJson = ""; + let telemetryJson = ""; + let readyCount = 0; + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + spawnedCommand = command; + if (command._tag === "StandardCommand") { + const fd3 = command.options.additionalFds?.fd3; + if (fd3?.type === "input" && fd3.stream) { + bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); + } + const fd4 = command.options.additionalFds?.fd4; + if (fd4?.type === "input" && fd4.stream) { + telemetryJson = yield* fd4.stream.pipe(Stream.decodeText(), Stream.mkString); } + } - return makeProcess({ - exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); + return makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); - const instance = yield* makeTestInstance({ - config: { - ...baseConfig, - bootstrap: configWithObservability, - }, - spawnerLayer: layerSpawner, - desktopTelemetryStream: Stream.encodeText( - Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'), - ), - onReady: Effect.sync(() => { - readyCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), - backendOutputLog: { - persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), - }, - }); + const instance = yield* makeTestInstance({ + config: { + ...baseConfig, + bootstrap: configWithObservability, + }, + spawnerLayer: layerSpawner, + desktopTelemetryStream: Stream.encodeText( + Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'), + ), + onReady: Effect.sync(() => { + readyCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), + backendOutputLog: { + persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), + }, + }); - yield* instance.start; - yield* Queue.take(exited); - - assert.equal(readyCount, 1); - assert.isDefined(spawnedCommand); - if (spawnedCommand._tag !== "StandardCommand") { - throw new Error("Expected backend to spawn a standard command."); - } - - assert.equal(spawnedCommand.command, "/electron"); - assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); - assert.equal(spawnedCommand.options.cwd, "/server"); - assert.equal(spawnedCommand.options.extendEnv, true); - assert.equal(spawnedCommand.options.stdout, "pipe"); - assert.equal(spawnedCommand.options.stderr, "pipe"); - assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); - assert.isDefined(spawnedCommand.options.forceKillAfter); - assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input"); - assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output"); - assert.equal( - Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), - 5_000, - ); - - assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); - assert.equal( - telemetryJson, - '{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n', - ); - }), - ), + yield* instance.start; + yield* Queue.take(exited); + + assert.equal(readyCount, 1); + assert.isDefined(spawnedCommand); + if (spawnedCommand._tag !== "StandardCommand") { + throw new Error("Expected backend to spawn a standard command."); + } + + assert.equal(spawnedCommand.command, "/electron"); + assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); + assert.equal(spawnedCommand.options.cwd, "/server"); + assert.equal(spawnedCommand.options.extendEnv, true); + assert.equal(spawnedCommand.options.stdout, "pipe"); + assert.equal(spawnedCommand.options.stderr, "pipe"); + assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); + assert.isDefined(spawnedCommand.options.forceKillAfter); + assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input"); + assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output"); + assert.equal( + Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), + 5_000, + ); + + assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); + assert.equal( + telemetryJson, + '{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n', + ); + }), ); it.effect("reports a SIGKILL exit without an exit code", () => @@ -344,285 +345,272 @@ describe("DesktopBackendManager", () => { ); it.effect("reports child exit before waiting for trailing output to drain", () => - Effect.scoped( - Effect.gen(function* () { - const exitObserved = yield* Deferred.make(); - const finishOutputDrain = yield* Deferred.make(); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.fromEffect( - Deferred.await(finishOutputDrain).pipe( - Effect.as(new TextEncoder().encode("trailing output\n")), - ), + Effect.gen(function* () { + const exitObserved = yield* Deferred.make(); + const finishOutputDrain = yield* Deferred.make(); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.fromEffect( + Deferred.await(finishOutputDrain).pipe( + Effect.as(new TextEncoder().encode("trailing output\n")), ), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - }), - ), + ), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }), ), - ); + ), + ); - const runFiber = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - onExitObserved: () => Deferred.succeed(exitObserved, void 0).pipe(Effect.asVoid), - }).pipe( - Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), - Effect.forkChild, - ); - - yield* Deferred.await(exitObserved); - assert.isUndefined(runFiber.pollUnsafe()); - - yield* Deferred.succeed(finishOutputDrain, void 0); - assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 1); - }), - ), + const runFiber = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + onExitObserved: () => Deferred.succeed(exitObserved, void 0).pipe(Effect.asVoid), + }).pipe(Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), Effect.forkChild); + + yield* Deferred.await(exitObserved); + assert.isUndefined(runFiber.pollUnsafe()); + + yield* Deferred.succeed(finishOutputDrain, void 0); + assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 1); + }), ); it.effect("continues routing desktop telemetry control messages after an invalid line", () => - Effect.scoped( - Effect.gen(function* () { - const handled = yield* Deferred.make(); - const controlMessage = encodeDesktopTelemetryControl({ - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - getOutputFd: (fd) => - fd === 5 - ? Stream.encodeText(Stream.make(`not-json\n${controlMessage}\n`)) - : Stream.empty, - exitCode: Deferred.await(handled).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }), - ), + Effect.gen(function* () { + const handled = yield* Deferred.make(); + const controlMessage = encodeDesktopTelemetryControl({ + version: 1, + type: "setDiagnosticsDemand", + enabled: true, + }); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + getOutputFd: (fd) => + fd === 5 + ? Stream.encodeText(Stream.make(`not-json\n${controlMessage}\n`)) + : Stream.empty, + exitCode: Deferred.await(handled).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), ), - ); - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - desktopTelemetryPublisher: { - handleControlForSource: (_sourceId, message) => - message.type === "setDiagnosticsDemand" - ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid) - : Effect.void, - }, - }); + ), + ); + const instance = yield* makeTestInstance({ + spawnerLayer: layerSpawner, + desktopTelemetryPublisher: { + handleControlForSource: (_sourceId, message) => + message.type === "setDiagnosticsDemand" + ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid) + : Effect.void, + }, + }); - yield* instance.start; - assert.isTrue(yield* Deferred.await(handled)); - }), - ), + yield* instance.start; + assert.isTrue(yield* Deferred.await(handled)); + }), ); it.effect("stops the scoped process with output still pending", () => - Effect.scoped( - Effect.gen(function* () { - let startCount = 0; - let closedCount = 0; - const closed = yield* Deferred.make(); - const teardownStarted = yield* Deferred.make(); - const finishTeardown = yield* Deferred.make(); - const startedPids = yield* Queue.unbounded(); - const ready = yield* Deferred.make(); - const backendReadyFlag = yield* Ref.make(false); - let shutdownCount = 0; - let persistedFailureCount = 0; - let discardedSessionCount = 0; - let removedTelemetrySources = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - startCount += 1; - yield* Queue.offer(startedPids, 123); - const close = Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishTeardown)), - Effect.andThen( - Effect.sync(() => { - closedCount += 1; - }), - ), - Effect.andThen(Deferred.succeed(closed, void 0)), - Effect.asVoid, - ); + Effect.gen(function* () { + let startCount = 0; + let closedCount = 0; + const closed = yield* Deferred.make(); + const teardownStarted = yield* Deferred.make(); + const finishTeardown = yield* Deferred.make(); + const startedPids = yield* Queue.unbounded(); + const ready = yield* Deferred.make(); + const backendReadyFlag = yield* Ref.make(false); + let shutdownCount = 0; + let persistedFailureCount = 0; + let discardedSessionCount = 0; + let removedTelemetrySources = 0; - yield* Scope.addFinalizer(scope, close); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + startCount += 1; + yield* Queue.offer(startedPids, 123); + const close = Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishTeardown)), + Effect.andThen( + Effect.sync(() => { + closedCount += 1; + }), + ), + Effect.andThen(Deferred.succeed(closed, void 0)), + Effect.asVoid, + ); + + yield* Scope.addFinalizer(scope, close); + + return makeProcess({ + stdout: Stream.never, + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + kill: () => close, + }); + }), + ), + ); - return makeProcess({ - stdout: Stream.never, - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - kill: () => close, - }); + const instance = yield* makeTestInstance({ + spawnerLayer: layerSpawner, + onReady: Ref.set(backendReadyFlag, true).pipe( + Effect.andThen(Deferred.succeed(ready, void 0)), + Effect.asVoid, + ), + onShutdown: Ref.set(backendReadyFlag, false).pipe( + Effect.andThen( + Effect.sync(() => { + shutdownCount += 1; }), ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - onReady: Ref.set(backendReadyFlag, true).pipe( - Effect.andThen(Deferred.succeed(ready, void 0)), - Effect.asVoid, - ), - onShutdown: Ref.set(backendReadyFlag, false).pipe( - Effect.andThen( - Effect.sync(() => { - shutdownCount += 1; - }), - ), - ), - backendOutputLog: { - persistFailure: () => - Effect.sync(() => { - persistedFailureCount += 1; - }), - discardSession: Effect.sync(() => { - discardedSessionCount += 1; + ), + backendOutputLog: { + persistFailure: () => + Effect.sync(() => { + persistedFailureCount += 1; }), - }, - desktopTelemetryPublisher: { - removeControlSource: () => - Effect.sync(() => { - removedTelemetrySources += 1; - }), - }, - }); - assert.isTrue(Option.isNone(yield* instance.currentConfig)); - - yield* instance.start; - assert.equal(yield* Queue.take(startedPids), 123); - yield* Deferred.await(ready); - assert.isTrue(yield* Ref.get(backendReadyFlag)); - assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); - - const runningSnapshot = yield* instance.snapshot; - assert.equal(runningSnapshot.ready, true); - assert.deepEqual(runningSnapshot.activePid, Option.some(123)); - - const stopFiber = yield* instance.stop().pipe(Effect.forkChild); - yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); - assert.isFalse(yield* Ref.get(backendReadyFlag)); - assert.equal(shutdownCount, 1); - yield* Deferred.succeed(finishTeardown, undefined); - yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); - assert.equal(startCount, 1); - assert.equal(closedCount, 1); - assert.equal(persistedFailureCount, 0); - assert.equal(discardedSessionCount, 1); - assert.equal(removedTelemetrySources, 1); - - const stoppedSnapshot = yield* instance.snapshot; - assert.isFalse(yield* Ref.get(backendReadyFlag)); - assert.equal(shutdownCount, 1); - assert.equal(stoppedSnapshot.desiredRunning, false); - assert.equal(stoppedSnapshot.ready, false); - assert.equal(Option.isNone(stoppedSnapshot.activePid), true); - }), - ), + discardSession: Effect.sync(() => { + discardedSessionCount += 1; + }), + }, + desktopTelemetryPublisher: { + removeControlSource: () => + Effect.sync(() => { + removedTelemetrySources += 1; + }), + }, + }); + assert.isTrue(Option.isNone(yield* instance.currentConfig)); + + yield* instance.start; + assert.equal(yield* Queue.take(startedPids), 123); + yield* Deferred.await(ready); + assert.isTrue(yield* Ref.get(backendReadyFlag)); + assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); + + const runningSnapshot = yield* instance.snapshot; + assert.equal(runningSnapshot.ready, true); + assert.deepEqual(runningSnapshot.activePid, Option.some(123)); + + const stopFiber = yield* instance.stop().pipe(Effect.forkChild); + yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); + assert.isFalse(yield* Ref.get(backendReadyFlag)); + assert.equal(shutdownCount, 1); + yield* Deferred.succeed(finishTeardown, undefined); + yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); + assert.equal(startCount, 1); + assert.equal(closedCount, 1); + assert.equal(persistedFailureCount, 0); + assert.equal(discardedSessionCount, 1); + assert.equal(removedTelemetrySources, 1); + + const stoppedSnapshot = yield* instance.snapshot; + assert.isFalse(yield* Ref.get(backendReadyFlag)); + assert.equal(shutdownCount, 1); + assert.equal(stoppedSnapshot.desiredRunning, false); + assert.equal(stoppedSnapshot.ready, false); + assert.equal(Option.isNone(stoppedSnapshot.activePid), true); + }), ); it.effect("restarts when start is requested during stop teardown", () => - Effect.scoped( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const teardownStarted = yield* Deferred.make(); - const finishTeardown = yield* Deferred.make(); - let startCount = 0; + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const teardownStarted = yield* Deferred.make(); + const finishTeardown = yield* Deferred.make(); + let startCount = 0; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - const closed = yield* Deferred.make(); - startCount += 1; - yield* Queue.offer(starts, startCount); - if (startCount === 1) { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishTeardown)), - Effect.andThen(Deferred.succeed(closed, undefined)), - Effect.asVoid, - ), - ); - } else { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(closed, undefined).pipe(Effect.asVoid), - ); - } - return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + startCount += 1; + yield* Queue.offer(starts, startCount); + if (startCount === 1) { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishTeardown)), + Effect.andThen(Deferred.succeed(closed, undefined)), + Effect.asVoid, + ), + ); + } else { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(closed, undefined).pipe(Effect.asVoid), + ); + } + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - }); + const instance = yield* makeTestInstance({ + spawnerLayer: layerSpawner, + httpClientLayer: layerHttpClient(() => Effect.never), + }); - yield* instance.start; - assert.equal(yield* Queue.take(starts), 1); + yield* instance.start; + assert.equal(yield* Queue.take(starts), 1); - const stopFiber = yield* instance.stop().pipe(Effect.forkChild); - yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); - yield* instance.start; - assert.equal((yield* instance.snapshot).desiredRunning, true); + const stopFiber = yield* instance.stop().pipe(Effect.forkChild); + yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); + yield* instance.start; + assert.equal((yield* instance.snapshot).desiredRunning, true); - yield* Deferred.succeed(finishTeardown, undefined); - yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); - yield* TestClock.adjust(Duration.millis(500)); + yield* Deferred.succeed(finishTeardown, undefined); + yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); + yield* TestClock.adjust(Duration.millis(500)); - assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); - const restarted = yield* instance.snapshot; - assert.equal(restarted.desiredRunning, true); - assert.deepEqual(restarted.activePid, Option.some(123)); - }).pipe(Effect.provide(TestClock.layer())), - ), + assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); + const restarted = yield* instance.snapshot; + assert.equal(restarted.desiredRunning, true); + assert.deepEqual(restarted.activePid, Option.some(123)); + }).pipe(Effect.provide(TestClock.layer())), ); it.effect("reports state-directory ownership once without scheduling another backend", () => - Effect.scoped( - Effect.gen(function* () { - let startCount = 0; - const refused = yield* Deferred.make(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Effect.succeed( - ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED), - ), - }); - }), - ), + Effect.gen(function* () { + let startCount = 0; + const refused = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Effect.succeed( + ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED), + ), + }); + }), ), - httpClientLayer: layerHttpClient(() => Effect.never), - onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid), - }); - yield* instance.start; - yield* Deferred.await(refused); - const snapshot = yield* instance.snapshot; - assert.isFalse(snapshot.desiredRunning); - assert.isFalse(snapshot.restartScheduled); - yield* TestClock.adjust(Duration.minutes(1)); - assert.equal(startCount, 1); - }).pipe(Effect.provide(TestClock.layer())), - ), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* Deferred.await(refused); + const snapshot = yield* instance.snapshot; + assert.isFalse(snapshot.desiredRunning); + assert.isFalse(snapshot.restartScheduled); + yield* TestClock.adjust(Duration.minutes(1)); + assert.equal(startCount, 1); + }).pipe(Effect.provide(TestClock.layer())), ); // A policy refusal is reported on the first exit, since retrying cannot fix it. @@ -632,145 +620,167 @@ describe("DesktopBackendManager", () => { ])( "reports exit $code with the server output after $exits exits", ({ code, exits, managedPolicy }) => - Effect.scoped( - Effect.gen(function* () { - const message = "Error: Cannot find module 'zod/v4' in /Users/René"; - let exitCount = 0; - const firstExit = yield* Deferred.make(); - const reports = yield* Queue.unbounded<{ - readonly exitCount: number; - readonly failure: DesktopBackendManager.BackendStartupFailure; - }>(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - // "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut - // lands inside the emoji's surrogate pair. - stderr: Stream.make( - new TextEncoder().encode( - `😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`, - ), - new Uint8Array([0xc3]), - new Uint8Array([0xa9]), + Effect.gen(function* () { + const message = "Error: Cannot find module 'zod/v4' in /Users/René"; + let exitCount = 0; + const firstExit = yield* Deferred.make(); + const reports = yield* Queue.unbounded<{ + readonly exitCount: number; + readonly failure: DesktopBackendManager.BackendStartupFailure; + }>(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + // "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut + // lands inside the emoji's surrogate pair. + stderr: Stream.make( + new TextEncoder().encode( + `😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`, ), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)), - }), - ), + new Uint8Array([0xc3]), + new Uint8Array([0xa9]), + ), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)), + }), ), ), - httpClientLayer: layerHttpClient(() => Effect.never), - backendOutputLog: { - persistFailure: () => { - exitCount += 1; - return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); - }, + ), + httpClientLayer: layerHttpClient(() => Effect.never), + backendOutputLog: { + persistFailure: () => { + exitCount += 1; + return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); }, - onStartupFailure: (failure) => - Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), - }); - yield* instance.start; - yield* Deferred.await(firstExit); - yield* TestClock.adjust(Duration.minutes(1)); - const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports); - assert.equal(exitsWhenReported, exits); - assert.equal(failure.reason, `The server could not start (code=${code}).`); - assert.equal(failure.managedPolicy, managedPolicy); - assert.isTrue(failure.output.endsWith(message)); - assert.isTrue(failure.output.isWellFormed()); - }).pipe(Effect.provide(TestClock.layer())), - ), + }, + onStartupFailure: (failure) => + Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* Deferred.await(firstExit); + yield* TestClock.adjust(Duration.minutes(1)); + const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports); + assert.equal(exitsWhenReported, exits); + assert.equal(failure.reason, `The server could not start (code=${code}).`); + assert.equal(failure.managedPolicy, managedPolicy); + assert.isTrue(failure.output.endsWith(message)); + assert.isTrue(failure.output.isWellFormed()); + }).pipe(Effect.provide(TestClock.layer())), ); it.effect("waits out a slow startup and reports one that never becomes ready", () => - Effect.scoped( - Effect.gen(function* () { - const reports = yield* Queue.unbounded(); - const startInstance = (status: Ref.Ref, onReady?: Effect.Effect) => - makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed(makeProcess({ exitCode: Effect.never })), + Effect.gen(function* () { + const reports = yield* Queue.unbounded(); + const startInstance = (status: Ref.Ref, onReady?: Effect.Effect, stdout = "") => + makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Effect.never, + stdout: Stream.make(new TextEncoder().encode(stdout)), + }), ), ), - httpClientLayer: layerHttpClient((request) => - Ref.get(status).pipe(Effect.map((code) => responseForRequest(request, code))), - ), - onStartupFailure: (failure) => Queue.offer(reports, failure).pipe(Effect.asVoid), - ...(onReady ? { onReady } : {}), - }).pipe(Effect.tap((instance) => instance.start)); - - const slowStatus = yield* Ref.make(503); - const ready = yield* Deferred.make(); - yield* startInstance(slowStatus, Deferred.succeed(ready, undefined).pipe(Effect.asVoid)); - yield* TestClock.adjust(Duration.seconds(66)); - yield* Ref.set(slowStatus, 200); - yield* TestClock.adjust(Duration.millis(100)); - yield* Deferred.await(ready); - assert.isTrue(Option.isNone(yield* Queue.poll(reports))); - - yield* startInstance(yield* Ref.make(503)); - yield* TestClock.adjust(Duration.seconds(179)); - assert.isTrue(Option.isNone(yield* Queue.poll(reports))); - yield* TestClock.adjust(Duration.seconds(1)); - const failure = yield* Queue.take(reports); - assert.equal(failure.reason, "The server started but did not become ready in time."); - }).pipe(Effect.provide(TestClock.layer())), - ), + ), + httpClientLayer: layerHttpClient((request) => + Ref.get(status).pipe(Effect.map((code) => responseForRequest(request, code))), + ), + onStartupFailure: (failure) => Queue.offer(reports, failure).pipe(Effect.asVoid), + ...(onReady ? { onReady } : {}), + }).pipe(Effect.tap((instance) => instance.start)); + + const slowStatus = yield* Ref.make(503); + const ready = yield* Deferred.make(); + yield* startInstance(slowStatus, Deferred.succeed(ready, undefined).pipe(Effect.asVoid)); + yield* TestClock.adjust(Duration.seconds(66)); + yield* Ref.set(slowStatus, 200); + yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(ready); + assert.isTrue(Option.isNone(yield* Queue.poll(reports))); + + const migratingStatus = yield* Ref.make(503); + const migrated = yield* Deferred.make(); + yield* startInstance( + migratingStatus, + Deferred.succeed(migrated, undefined).pipe(Effect.asVoid), + // Enough progress lines that the start line scrolls out of the output tail. + `INFO ${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n` + + Array.from( + { length: 80 }, + (_, index) => + `INFO ${DATABASE_MIGRATION_PROGRESS_MESSAGE} migration=${index}_Migration progress="${index + 1} of 80"\n`, + ).join(""), + ); + yield* TestClock.adjust(Duration.minutes(10)); + assert.isTrue(Option.isNone(yield* Queue.poll(reports))); + yield* Ref.set(migratingStatus, 200); + yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(migrated); + + yield* startInstance( + yield* Ref.make(503), + undefined, + `${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n${DATABASE_MIGRATIONS_FINISHED_MESSAGE}\n`, + ); + yield* TestClock.adjust(Duration.seconds(179)); + assert.isTrue(Option.isNone(yield* Queue.poll(reports))); + yield* TestClock.adjust(Duration.seconds(1)); + const failure = yield* Queue.take(reports); + assert.equal(failure.reason, "The server started but did not become ready in time."); + }).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)), - ), - }); - }), - ), + 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())), - ), + ), + 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 d24b2f52f295c..89ebc9e862ecf 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -25,6 +25,7 @@ import { DesktopTelemetryControlMessage, type DesktopTelemetryControlMessage as DesktopTelemetryControlMessageValue, } from "@t3tools/contracts"; +import { isRunningDatabaseMigrations } from "@t3tools/shared/databaseMigrationLog"; import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; import * as DesktopObservability from "../app/DesktopObservability.ts"; @@ -204,7 +205,7 @@ interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly outputDrainTimeout?: Duration.Duration; readonly onStarted?: (pid: number) => Effect.Effect; readonly onExitObserved?: () => Effect.Effect; - readonly onReady?: () => Effect.Effect; + readonly onReady?: (pid: number) => Effect.Effect; readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect; readonly onOutput?: ( streamName: BackendProcessOutputStream, @@ -251,8 +252,8 @@ export interface BackendInstanceSpec { readonly id: BackendInstanceId; readonly label: Effect.Effect; readonly configResolve: Effect.Effect; - /** Receives the ready run's resolved URL; reading current config could race a subsequent restart. */ - readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; + /** Receives the ready run's URL and pid; reading current config could race a subsequent restart. */ + readonly onReady?: (httpBaseUrl: URL, pid: number) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; readonly onStateDirOwned?: () => Effect.Effect; @@ -577,7 +578,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( httpBaseUrl: options.httpBaseUrl, timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, }).pipe( - Effect.flatMap(() => options.onReady?.() ?? Effect.void), + Effect.flatMap(() => options.onReady?.(handle.pid) ?? Effect.void), Effect.as(true), Effect.catchTags({ BackendReadinessTimeoutError: (error) => @@ -938,7 +939,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ...run, exitObserved: true, })), - onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { + onReady: Effect.fn("desktop.backendInstance.onReady")(function* (pid) { const isCurrentRun = yield* SubscriptionRef.modify(state, (latest) => { const activeRun = Option.getOrUndefined(latest.active); if (activeRun?.id !== runId) { @@ -958,7 +959,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } - yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; + yield* spec.onReady?.(config.value.httpBaseUrl, pid) ?? Effect.void; if ( config.value.runningDistro !== undefined && config.value.wslRuntimeId !== undefined @@ -971,6 +972,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }), onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")( function* (error) { + if (isRunningDatabaseMigrations(yield* Ref.get(outputTail))) { + yield* logInstanceWarning("backend is still migrating its database; waiting", { + error: error.message, + }); + return; + } yield* logInstanceWarning("backend readiness check failed during bootstrap", { error: error.message, }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index c46e0b3a26078..8e9f4320c08c7 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -1,5 +1,5 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import { DESKTOP_UPDATE_IN_PROGRESS_TTL_MS } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -110,27 +110,52 @@ const runningConfig = { function layerPool( labelRef: Ref.Ref, - backend?: Effect.Success>, + options: { + readonly config?: DesktopBackendStartConfig; + readonly fileSystem?: Partial; + readonly spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly httpClient?: HttpClient.HttpClient; + readonly onReady?: Effect.Effect; + readonly backend?: Effect.Success>; + } = {}, ): Layer.Layer { return DesktopBackendPool.layer.pipe( Layer.provideMerge( Layer.mergeAll( - FileSystem.layerNoop({ exists: () => Effect.succeed(true) }), + DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: "/test-home", + platform: "linux", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(Path.layer, DesktopConfig.layerTest({ T3CODE_HOME: "/test-home" })), + ), + Layer.orDie, + ), + FileSystem.layerNoop(options.fileSystem ?? { exists: () => Effect.succeed(true) }), Path.layer, - backend?.layer ?? + options.backend?.layer ?? Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), + options.spawner ?? + ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), ), Layer.succeed( HttpClient.HttpClient, - HttpClient.make((request) => - backend === undefined - ? Effect.die("unexpected HTTP request") - : Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), - ), - ), + options.httpClient ?? + HttpClient.make((request) => + options.backend === undefined + ? Effect.die("unexpected HTTP request") + : Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), + ), + ), ), Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { forInstance: () => @@ -155,26 +180,14 @@ function layerPool( }), DesktopBrowserHost.layer, Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { - resolvePrimary: Effect.die("unexpected primary config resolve"), + resolvePrimary: options.config + ? Effect.succeed(options.config) + : Effect.die("unexpected primary config resolve"), resolvePrimaryLabel: Ref.get(labelRef), resolveWsl: () => Effect.die("unexpected WSL config resolve"), currentBootstrapToken: Effect.die("unexpected bootstrap token read"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), DesktopAppSettings.layerTest(), - DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/src", - homeDirectory: "/home/test", - platform: "linux", - processArch: "x64", - appVersion: "0.0.0", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), - Layer.orDie, - ), DesktopState.layer, DesktopWslEnvironment.layerTest(), ElectronDialog.layer, @@ -186,7 +199,7 @@ function layerPool( activate: Effect.die("unexpected window activate"), createMainIfBackendReady: Effect.die("unexpected window create"), showConnectingSplash: Effect.void, - handleBackendReady: () => Effect.void, + handleBackendReady: () => options.onReady ?? Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, prepareCaptureReveal: Effect.void, @@ -201,86 +214,268 @@ function layerPool( ); } +const markerPath = "/test-home/runtime/desktop-update-in-progress"; +const runtimePath = "/test-home/userdata/server-runtime.json"; + +const makeUpdateMarkerFixture = () => + Effect.gen(function* () { + const allowReady = yield* Deferred.make(); + const runtimeChecked = yield* Deferred.make(); + const markerRemoved = yield* Deferred.make(); + const readyRuns = yield* Queue.unbounded(); + let nextPid = 123; + const files = new Map([[markerPath, JSON.stringify({ startedAt: 0, expiresAt: 60_000 })]]); + const removed: string[] = []; + const config: DesktopBackendStartConfig = { + executablePath: "/electron", + args: [], + entryPath: "/server/bin.mjs", + cwd: "/server", + env: {}, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3774, + t3Home: "/test-home", + 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(), + }; + const layer = layerPool(Ref.makeUnsafe("Local"), { + config, + fileSystem: { + exists: (path) => Effect.succeed(files.has(path) || path === config.entryPath), + readFileString: (path) => + (path === runtimePath ? Deferred.succeed(runtimeChecked, undefined) : Effect.void).pipe( + Effect.andThen(Effect.sync(() => files.get(path) ?? "")), + ), + remove: (path) => + Effect.sync(() => { + removed.push(path); + files.delete(path); + }).pipe( + Effect.andThen( + path === markerPath ? Deferred.succeed(markerRemoved, undefined) : Effect.void, + ), + Effect.asVoid, + ), + }, + spawner: ChildProcessSpawner.make(() => + Effect.gen(function* () { + const exited = yield* Deferred.make(); + const exit = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe( + Effect.asVoid, + ); + // Like the real spawner, closing the run's scope ends the process. + yield* Effect.addFinalizer(() => exit); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(nextPid++), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exited), + isRunning: Effect.succeed(true), + kill: () => exit, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), + ), + httpClient: HttpClient.make((request) => + Deferred.await(allowReady).pipe( + Effect.as(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), + ), + onReady: Queue.offer(readyRuns, undefined).pipe(Effect.asVoid), + }); + const publishRuntime = (pid: number) => + files.set( + runtimePath, + JSON.stringify({ + version: 1, + pid, + port: 3774, + origin: "http://127.0.0.1:3774", + startedAt: "2026-10-27T00:00:00.000Z", + }), + ); + return { + layer, + files, + removed, + allowReady, + readyRuns, + runtimeChecked, + markerRemoved, + publishRuntime, + }; + }); + describe("DesktopBackendPool", () => { - it.effect("unregister during pool quit does not interrupt sibling stops", () => - Effect.scoped( + it.effect( + "keeps the update marker after HTTP readiness until the backend publishes its runtime", + () => Effect.gen(function* () { - const backend = yield* makeFakeBackendProcess(); + const fixture = yield* makeUpdateMarkerFixture(); 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); + const primary = yield* pool.primary; + yield* primary.start; + yield* Deferred.succeed(fixture.allowReady, undefined); + yield* Queue.take(fixture.readyRuns); + assert.isTrue( + fixture.files.has(markerPath), + "a launcher could neither wait nor adopt between readiness and publication", + ); + yield* Deferred.await(fixture.runtimeChecked); 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))); + assert.isTrue(fixture.files.has(markerPath)); + fixture.publishRuntime(456); + yield* TestClock.adjust(Duration.seconds(1)); + assert.isTrue( + fixture.files.has(markerPath), + "another process's runtime is not the replacement", + ); + fixture.publishRuntime(123); + yield* TestClock.adjust(Duration.seconds(1)); + yield* Deferred.await(fixture.markerRemoved); + assert.deepEqual(fixture.removed, [markerPath]); + }).pipe(Effect.provide(fixture.layer)); }), - ), ); - it.effect("unregister stops the registered instance", () => - Effect.scoped( - Effect.gen(function* () { - const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); + it.effect("leaves a replacement update marker written while an earlier wait is pending", () => + Effect.gen(function* () { + const fixture = yield* makeUpdateMarkerFixture(); + yield* Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primary = yield* pool.primary; + yield* primary.start; + yield* Deferred.succeed(fixture.allowReady, undefined); + yield* Queue.take(fixture.readyRuns); + yield* Deferred.await(fixture.runtimeChecked); + yield* TestClock.adjust(Duration.millis(DESKTOP_UPDATE_IN_PROGRESS_TTL_MS - 1_000)); + const replacement = JSON.stringify({ startedAt: 59_000, expiresAt: 119_000 }); + fixture.files.set(markerPath, replacement); + yield* TestClock.adjust(Duration.millis(DESKTOP_UPDATE_IN_PROGRESS_TTL_MS)); + assert.equal(fixture.files.get(markerPath), replacement); + assert.deepEqual(fixture.removed, []); + }).pipe(Effect.provide(fixture.layer)); + }), + ); + + it.effect("a later backend run's readiness supersedes the earlier run's marker wait", () => + Effect.gen(function* () { + const fixture = yield* makeUpdateMarkerFixture(); + yield* Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primary = yield* pool.primary; + yield* primary.start; + yield* Deferred.succeed(fixture.allowReady, undefined); + yield* Queue.take(fixture.readyRuns); + yield* Deferred.await(fixture.runtimeChecked); + yield* TestClock.adjust(Duration.seconds(30)); + yield* primary.stop(); + yield* primary.start; + yield* Queue.take(fixture.readyRuns); + yield* TestClock.adjust(Duration.seconds(31)); + assert.isTrue( + fixture.files.has(markerPath), + "the earlier run's wait must not end the later run's handoff", + ); + assert.deepEqual(fixture.removed, []); + fixture.publishRuntime(124); + yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(fixture.markerRemoved); + assert.deepEqual(fixture.removed, [markerPath]); + }).pipe(Effect.provide(fixture.layer)); + }), + ); + + it.effect("unregister during pool quit does not interrupt sibling stops", () => + 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, - preflightFailure: Option.some({ reason: "WSL starting", fatal: false }), - }), + configResolve: Effect.succeed(runningConfig), + onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid), }); yield* instance.start; - assert.equal((yield* instance.snapshot).restartScheduled, true); + 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); - yield* pool.unregister(id); + 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 }))); + }), + ); - const stopped = yield* instance.snapshot; - assert.equal(stopped.desiredRunning, false); - assert.equal(stopped.restartScheduled, false); - assert.equal(Option.isNone(yield* pool.get(id)), true); - }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows")))), - ), + it.effect("unregister stops the registered instance", () => + Effect.gen(function* () { + const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const instance = yield* pool.register({ + id, + label: Effect.succeed("WSL (Ubuntu)"), + configResolve: Effect.succeed({ + ...runningConfig, + preflightFailure: Option.some({ reason: "WSL starting", fatal: false }), + }), + }); + yield* instance.start; + assert.equal((yield* instance.snapshot).restartScheduled, true); + + yield* pool.unregister(id); + + const stopped = yield* instance.snapshot; + assert.equal(stopped.desiredRunning, false); + assert.equal(stopped.restartScheduled, false); + assert.equal(Option.isNone(yield* pool.get(id)), true); + }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows")))), ); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 82d542b322c84..d69e1ce1427fe 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -1,10 +1,17 @@ +import { + DESKTOP_UPDATE_IN_PROGRESS_MARKER_FILE, + DESKTOP_UPDATE_IN_PROGRESS_TTL_MS, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FiberHandle from "effect/FiberHandle"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -31,6 +38,10 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; const { logWarning: logBackendPoolWarning } = DesktopObservability.makeComponentLogger("desktop-backend-pool"); +const decodeServerRuntime = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ pid: Schema.Number, port: Schema.Number })), +); + export type BackendInstanceId = DesktopBackendManager.BackendInstanceId; export const BackendInstanceId = DesktopBackendManager.BackendInstanceId; export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID; @@ -106,6 +117,8 @@ type UnregisterAction = export const layer = Layer.effect( DesktopBackendPool, Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const desktopWindow = yield* DesktopWindow.DesktopWindow; const electronDialog = yield* ElectronDialog.ElectronDialog; @@ -119,6 +132,41 @@ export const layer = Layer.effect( FileSystem.FileSystem | Path.Path | DesktopEnvironment.DesktopEnvironment >(); + const updateMarkerPath = environment.path.join( + environment.baseDir, + "runtime", + DESKTOP_UPDATE_IN_PROGRESS_MARKER_FILE, + ); + const serverRuntimePath = environment.path.join(environment.stateDir, "server-runtime.json"); + // The server publishes its runtime after HTTP readiness. Until then an SSH launcher + // finds nothing to adopt, so dropping the marker earlier lets it start a competing server. + const releaseUpdateMarker = Effect.fn("desktop.backendPool.releaseUpdateMarker")(function* ( + httpBaseUrl: URL, + pid: number, + ) { + const marker = yield* fileSystem.readFileString(updateMarkerPath).pipe(Effect.option); + if (Option.isNone(marker)) { + return; + } + yield* fileSystem.readFileString(serverRuntimePath).pipe( + Effect.flatMap(decodeServerRuntime), + Effect.map((runtime) => runtime.pid === pid && runtime.port === Number(httpBaseUrl.port)), + Effect.orElseSucceed(() => false), + Effect.repeat({ + until: (published) => published, + schedule: Schedule.spaced(Duration.millis(100)), + }), + Effect.timeoutOption(Duration.millis(DESKTOP_UPDATE_IN_PROGRESS_TTL_MS)), + ); + // A later update may have replaced the marker; that handoff is not ours to end. + const current = yield* fileSystem.readFileString(updateMarkerPath).pipe(Effect.option); + if (Option.isSome(current) && current.value === marker.value) { + yield* fileSystem.remove(updateMarkerPath, { force: true }).pipe(Effect.ignore); + } + }); + // One wait at a time: each run's shutdown or readiness ends the previous run's wait. + const updateMarkerWait = yield* FiberHandle.make(); + // Persist fatal Windows fallbacks; transient transport failures must preserve the user's WSL preference. const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( function* (failure: DesktopBackendManager.PreflightFailure) { @@ -165,8 +213,9 @@ export const layer = Layer.effect( label: configuration.resolvePrimaryLabel, configResolve: configuration.resolvePrimary, // Absorb window errors so readiness can reset restartAttempt. - onReady: (httpBaseUrl) => - desktopWindow.handleBackendReady(httpBaseUrl).pipe( + onReady: (httpBaseUrl, pid) => + FiberHandle.run(updateMarkerWait, releaseUpdateMarker(httpBaseUrl, pid)).pipe( + Effect.andThen(desktopWindow.handleBackendReady(httpBaseUrl)), // Only once the main window exists, so the recovery window never closes into nothing. Effect.andThen(SubscriptionRef.set(primaryBackendStartup, { _tag: "Ready" })), Effect.catch((error) => @@ -177,7 +226,10 @@ export const layer = Layer.effect( ), onStartupFailure: (failure) => SubscriptionRef.set(primaryBackendStartup, { _tag: "Failing", ...failure }), - onShutdown: () => desktopWindow.handleBackendNotReady, + onShutdown: () => + FiberHandle.clear(updateMarkerWait).pipe( + Effect.andThen(desktopWindow.handleBackendNotReady), + ), onPreflightFailed: handlePrimaryPreflightFailure, onStateDirOwned: () => DesktopHomeOwner.readLiveHomeOwner().pipe( diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 054655917cecc..397a757704835 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { HttpClient } from "effect/http"; +import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; @@ -79,4 +79,41 @@ describe("tailscale endpoint provider", () => { ); }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); + + it.effect("offers Tailscale HTTPS only when Serve reaches this desktop's backend", () => + Effect.gen(function* () { + const magicDnsStatus = (servedEnvironmentId: string) => + resolveTailscaleAdvertisedEndpoints({ + port: 3773, + serveEnabled: true, + networkInterfaces: {}, + statusJson: `{"Self":{"DNSName":"desktop.example.ts.net.","TailscaleIPs":[]}}`, + }).pipe( + Effect.map((endpoints) => endpoints.map((endpoint) => endpoint.status)), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + environmentId: + new URL(request.url).hostname === "127.0.0.1" + ? "desktop-backend" + : servedEnvironmentId, + label: "T3 server", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.1.9", + capabilities: { repositoryIdentity: true }, + }), + ), + ), + ), + ), + ); + + assert.deepEqual(yield* magicDnsStatus("desktop-backend"), ["available"]); + assert.deepEqual(yield* magicDnsStatus("another-server"), ["unavailable"]); + }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), + ); }); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 4b75afccdb5bc..1b8d9dff4ed4d 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -1,5 +1,9 @@ import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint"; -import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; +import { + ExecutionEnvironmentDescriptor, + type AdvertisedEndpoint, + type AdvertisedEndpointProvider, +} from "@t3tools/contracts"; import { buildTailscaleHttpsBaseUrl, isTailscaleIpv4Address, @@ -11,6 +15,7 @@ import { import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; @@ -71,10 +76,20 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { return endpoints; } +// The backend may run elsewhere (WSL), so ask it for its id instead of reading the state dir. +const probeServesThisBackend = (localHttpUrl: string) => (baseUrl: string) => + HttpClient.get(new URL("/.well-known/t3/environment", localHttpUrl)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)), + Effect.flatMap(({ environmentId }) => probeTailscaleHttpsEndpoint({ baseUrl, environmentId })), + Effect.orElseSucceed(() => false), + ); + const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( "resolveTailscaleMagicDnsAdvertisedEndpoint", )(function* (input: { readonly dnsName: string | null; + readonly localHttpUrl: string; readonly serveEnabled: boolean; readonly servePort?: number; readonly probe?: (baseUrl: string) => Effect.Effect; @@ -87,11 +102,7 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( magicDnsName: input.dnsName, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), }); - const probe = - input.probe?.(httpBaseUrl) ?? - probeTailscaleHttpsEndpoint({ - baseUrl: httpBaseUrl, - }); + const probe = (input.probe ?? probeServesThisBackend(input.localHttpUrl))(httpBaseUrl); const isReachable = input.serveEnabled ? yield* probe : false; return Option.some( @@ -140,6 +151,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints({ ...input, status }); const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ dnsName: status?.magicDnsName ?? null, + localHttpUrl: `http://127.0.0.1:${input.port}`, serveEnabled: input.serveEnabled === true, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), ...(input.probe === undefined ? {} : { probe: input.probe }), diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index aec3a2d5b45c0..b0cf4823126e2 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -63,7 +63,7 @@ describe("ElectronProtocol", () => { assert.equal((yield* request("/%invalid")).status, 400); assert.equal((yield* request("/", { method: "POST" })).status, 405); assert.equal(netFetchMock.mock.calls.length, 0); - }).pipe(Effect.provide(Layer.merge(layerProtocol, NodeServices.layer)), Effect.scoped), + }).pipe(Effect.provide(Layer.merge(layerProtocol, NodeServices.layer))), ); it.effect("proxies the stable renderer origin to the current app server", () => diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 56f70b3c66f4d..14f81d27b776f 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -87,18 +87,30 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); - it.effect("opens only the bare Grok Bot launch link", () => + it.effect("opens only the Grok Bot prompt link", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); + const prompt = "grokbot://app/v1/prompt?text=Add%20the%20MCP%20server"; const electronShell = yield* ElectronShell.ElectronShell; const results = yield* Effect.forEach( - ["grokbot://app", "grokbot://app/v1/prompt?id=bot&text=hi", "grokbot://"], + [ + prompt, + "grokbot://app", + "grokbot://app/v1/prompt?id=bot&text=hi", + "grokbot://app/v1/prompt?text=a&text=b", + "grokbot://app/v1/other?text=hi", + "grokbot://app/v1/prompt?text=hi#x", + "grokbot://:secret@app/v1/prompt?text=hi", + "grokbot://user@app/v1/prompt?text=hi", + "grokbot://other/v1/prompt?text=hi", + "other://app/v1/prompt?text=hi", + ], (url) => electronShell.openExternal(url), ); - assert.deepEqual(results, [true, false, false]); - assert.deepEqual(openExternalMock.mock.calls, [["grokbot://app"]]); + assert.deepEqual(results, [true, ...Array(9).fill(false)]); + assert.deepEqual(openExternalMock.mock.calls, [[prompt]]); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 0871330087038..4dd22fe7d39e3 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -21,9 +21,16 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); -// "Use T3 Code in another app" opens these apps (apps/web/src/components/mcp/mcpApps.ts). List +// "Use T3 Code in another app" opens these apps (apps/web/src/components/mcp/mcpApps.ts). Match // each exact link shape the wizard sends, never a whole scheme. -const APP_LAUNCH_URLS = new Set(["grokbot://app"]); +const isAppLaunchUrl = (url: URL) => + url.protocol === "grokbot:" && + url.host === "app" && + url.pathname === "/v1/prompt" && + url.username.length === 0 && + url.password.length === 0 && + url.hash.length === 0 && + [...url.searchParams.keys()].join() === "text"; // Zed embeds [user@]host in the path; reject passwords and ports there. const ZED_SSH_PATHNAME = /^\/(?:[^/@:]+@)?[^/@:]+\/.*$/; @@ -54,7 +61,7 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { try { const url = new URL(rawUrl); return SAFE_WEB_PROTOCOLS.has(url.protocol) || - APP_LAUNCH_URLS.has(url.href) || + isAppLaunchUrl(url) || isRemoteEditorUrl(url) || isLinearAppUrl(url) || isLocalEditorFileUrl(url) diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 4239bfe4c5bca..47aa0ce1f8df8 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -42,7 +42,7 @@ describe("ElectronTheme", () => { assert.equal(nativeTheme.themeSource, "light"); portal.report(null); assert.equal(nativeTheme.themeSource, "system"); - }).pipe(Effect.scoped), + }), ); it.effect("keeps an explicit appearance over the desktop color scheme", () => @@ -57,7 +57,7 @@ describe("ElectronTheme", () => { yield* theme.setSource("system"); assert.equal(nativeTheme.themeSource, "dark"); - }).pipe(Effect.scoped), + }), ); it.effect("stops following the desktop when the layer is released", () => @@ -76,6 +76,6 @@ describe("ElectronTheme", () => { }); yield* theme.setSource("system"); assert.equal(nativeTheme.themeSource, "system"); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts index 82c21512e24d4..7f989346dfd55 100644 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -75,5 +75,5 @@ it.effect("answers only app windows' top frames", () => appSubframe: ["rejected", undefined], previewGuest: ["rejected", undefined], }); - }).pipe(Effect.scoped), + }), ); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4db5ff5505d96..eecf1bb45e162 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -227,7 +227,6 @@ const layerDesktopApplication = Layer.mergeAll( DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopCliCommand.layer, - layerDesktopSsh, // Shares the quick-message layer merged below. Appearance only asks isPanel. DesktopAppearance.layer, ).pipe( @@ -240,6 +239,8 @@ const layerDesktopApplication = Layer.mergeAll( Layer.provide(DesktopManagedPolicy.layer), ), ), + // Updates closes the same tunnels the SSH IPC opens before quitAndInstall. + Layer.provideMerge(layerDesktopSsh), Layer.provideMerge(layerDesktopWslBackend), Layer.provideMerge(layerDesktopLocalEnvironmentAuth), ); diff --git a/apps/desktop/src/preview/AnnotationStroke.test.ts b/apps/desktop/src/preview/AnnotationStroke.test.ts new file mode 100644 index 0000000000000..ad8a21c140b45 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationStroke.test.ts @@ -0,0 +1,73 @@ +import type { PreviewAnnotationPoint } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + addStrokePoint, + openStrokePath, + startStroke, + strokeBounds, + STROKE_SEGMENTS_PER_PATH, +} from "./AnnotationStroke.ts"; + +// The Draw tool used to rebuild the whole path and rescan every point on each move. +const fullPath = (points: ReadonlyArray): string => { + let path = `M ${points[0]!.x} ${points[0]!.y}`; + for (let index = 1; index < points.length - 1; index += 1) { + const current = points[index]!; + const next = points[index + 1]!; + path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; + } + const last = points[points.length - 1]!; + return `${path} L ${last.x} ${last.y}`; +}; + +const fullBounds = (points: ReadonlyArray, width: number) => { + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const padding = width + 3; + const left = Math.min(...xs) - padding; + const top = Math.min(...ys) - padding; + const right = Math.max(...xs) + padding; + const bottom = Math.max(...ys) + padding; + return { x: left, y: top, width: right - left, height: bottom - top }; +}; + +// Each later path starts with a move to where the previous one ended. +const joinPaths = (paths: ReadonlyArray): string => + paths + .map((path, index) => { + if (index === 0) return path; + const resume = path.match(/^M (\S+ \S+)/)![1]!; + expect(paths[index - 1]!.endsWith(` ${resume}`)).toBe(true); + return path.slice(`M ${resume}`.length); + }) + .join(""); + +describe("stroke geometry", () => { + it("draws the same curve and bounds as a full rescan at every length", () => { + const points = Array.from({ length: STROKE_SEGMENTS_PER_PATH * 2 + 30 }, (_, index) => ({ + x: index * 3 + ((index * 7) % 11), + y: 200 + ((index * 13) % 29) - index, + })); + const geometry = startStroke(points[0]!); + const closedPaths: string[] = []; + for (let index = 1; index < points.length; index += 1) { + const closedPath = addStrokePoint(geometry, points[index]!); + if (closedPath !== null) closedPaths.push(closedPath); + const drawn = points.slice(0, index + 1); + expect(joinPaths([...closedPaths, openStrokePath(geometry)])).toBe(fullPath(drawn)); + expect(strokeBounds(geometry, 4)).toEqual(fullBounds(drawn, 4)); + } + expect(closedPaths).toHaveLength(2); + expect(geometry.points).toEqual(points); + }); + + it("keeps the rewritten path short for a stroke past the engine's argument limit", () => { + const geometry = startStroke({ x: 0, y: 0 }); + for (let index = 1; index < 250_000; index += 1) { + addStrokePoint(geometry, { x: index % 800, y: index % 600 }); + } + expect(strokeBounds(geometry, 4)).toEqual({ x: -7, y: -7, width: 813, height: 613 }); + expect(openStrokePath(geometry).length).toBeLessThan(STROKE_SEGMENTS_PER_PATH * 40); + }); +}); diff --git a/apps/desktop/src/preview/AnnotationStroke.ts b/apps/desktop/src/preview/AnnotationStroke.ts new file mode 100644 index 0000000000000..66510fc0b7cfe --- /dev/null +++ b/apps/desktop/src/preview/AnnotationStroke.ts @@ -0,0 +1,69 @@ +import type { PreviewAnnotationPoint, PreviewAnnotationRect } from "@t3tools/contracts"; + +// Setting a path's `d` makes Chromium parse the whole string again, so a long +// stroke is drawn as a run of paths and only the last one is rewritten per move. +export const STROKE_SEGMENTS_PER_PATH = 100; + +export interface StrokeGeometry { + readonly points: PreviewAnnotationPoint[]; + minX: number; + minY: number; + maxX: number; + maxY: number; + // The curve through the newest point is still open: where it ends depends on + // the point after it. This holds only the settled segments of the last path. + openPath: string; + openSegments: number; +} + +export function startStroke(point: PreviewAnnotationPoint): StrokeGeometry { + return { + points: [point], + minX: point.x, + minY: point.y, + maxX: point.x, + maxY: point.y, + openPath: `M ${point.x} ${point.y}`, + openSegments: 0, + }; +} + +// Returns the final `d` of the path this point filled, if it filled one. +export function addStrokePoint( + geometry: StrokeGeometry, + point: PreviewAnnotationPoint, +): string | null { + const previous = geometry.points[geometry.points.length - 1]!; + let closedPath: string | null = null; + if (geometry.points.length > 1) { + const midX = (previous.x + point.x) / 2; + const midY = (previous.y + point.y) / 2; + geometry.openPath += ` Q ${previous.x} ${previous.y} ${midX} ${midY}`; + geometry.openSegments += 1; + if (geometry.openSegments === STROKE_SEGMENTS_PER_PATH) { + closedPath = geometry.openPath; + geometry.openPath = `M ${midX} ${midY}`; + geometry.openSegments = 0; + } + } + geometry.points.push(point); + geometry.minX = Math.min(geometry.minX, point.x); + geometry.minY = Math.min(geometry.minY, point.y); + geometry.maxX = Math.max(geometry.maxX, point.x); + geometry.maxY = Math.max(geometry.maxY, point.y); + return closedPath; +} + +export function openStrokePath(geometry: StrokeGeometry): string { + const last = geometry.points[geometry.points.length - 1]!; + return `${geometry.openPath} L ${last.x} ${last.y}`; +} + +export function strokeBounds(geometry: StrokeGeometry, width: number): PreviewAnnotationRect { + const padding = width + 3; + const left = geometry.minX - padding; + const top = geometry.minY - padding; + const right = geometry.maxX + padding; + const bottom = geometry.maxY + padding; + return { x: left, y: top, width: right - left, height: bottom - top }; +} diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 9528cd5d2e846..484ba6f9358f6 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -33,6 +33,7 @@ const layerRejectedBeforeSession = Layer.succeed( isPartition: () => false, getSession: () => Effect.die("getSession must not be reached"), clearCookies: () => Effect.die("clearCookies must not be reached"), + cookiesCleared: () => Effect.die("cookiesCleared must not be reached"), clearCache: () => Effect.die("clearCache must not be reached"), }), ); @@ -88,7 +89,7 @@ describe("BrowserImport.importCookies", () => { assert.instanceOf(error, BrowserImport.BrowserImportFailedError); assert.equal(error.reason, "unknownSourceProfile"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect.skipIf(!symlinksSupported)( @@ -113,7 +114,7 @@ describe("BrowserImport.importCookies", () => { assert.instanceOf(error, BrowserImport.BrowserImportFailedError); assert.equal(error.reason, "browserRunning"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index e5aa05f268e43..92ab35320dab7 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -134,7 +134,7 @@ describe("readChromiumCookieDatabase", () => { const partitioned = yield* readChromiumCookieDatabase(filename, keys, "linux"); expect(partitioned.cookies).toEqual([]); expect(partitioned.undecryptable).toBe(1); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () => @@ -186,7 +186,7 @@ describe("readChromiumCookieDatabase", () => { { name: "encrypted", value: "stored encrypted" }, { name: "empty", value: "" }, ]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("enforces domain binding only for schema 24 and newer", () => @@ -227,7 +227,7 @@ describe("readChromiumCookieDatabase", () => { ]); expect(result.undecryptable).toBe(2); expect(result.undecryptableHosts).toEqual(["wrong.example", "short.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("decrypts mixed v10 and v11 cookies with their respective keys", () => @@ -273,7 +273,7 @@ describe("readChromiumCookieDatabase", () => { ]); expect(v10Only.undecryptable).toBe(1); expect(v10Only.undecryptableHosts).toEqual(["v11.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("recovers records written with the empty-passphrase key", () => @@ -320,7 +320,7 @@ describe("readChromiumCookieDatabase", () => { const noV11 = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcEmpty }, "linux"); expect(noV11.cookies.map(({ name }) => name)).toEqual(["empty-v10"]); expect(noV11.undecryptableHosts).toEqual(["ev11.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("treats unversioned encrypted values as legacy plaintext on macOS and Linux", () => @@ -355,7 +355,7 @@ describe("readChromiumCookieDatabase", () => { expect(mac.undecryptable).toBe(0); expect(linux.cookies[0]?.value).toBe("legacy cleartext"); expect(linux.undecryptable).toBe(0); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("skips partitioned cookies without breaking pre-CHIPS schemas", () => @@ -409,6 +409,6 @@ describe("readChromiumCookieDatabase", () => { expect(chips.cookies.map(({ name }) => name)).toEqual(["plain"]); expect(chips.undecryptable).toBe(1); expect(chips.undecryptableHosts).toEqual(["partitioned.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts index 402397d3fde60..ca0a86cedacd3 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts @@ -11,7 +11,7 @@ import { snapshotCookieDatabase } from "./CookieDatabase.ts"; const runNode = ( effect: Effect.Effect, -) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); +) => effect.pipe(Effect.provide(NodeServices.layer)); describe("snapshotCookieDatabase", () => { it.effect("includes committed WAL data in one consistent database", () => diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index be41318fa01e6..33e32ec030be3 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -78,7 +78,7 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( }); const run = (effect: Effect.Effect) => - effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + effect.pipe(Effect.provide(NodeServices.layer)); describe("readFirefoxCookies", () => { it.effect("converts millisecond expiries from schema 16 and newer", () => diff --git a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts index 8358088bc1344..8ef376e4887bf 100644 --- a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts +++ b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts @@ -64,6 +64,6 @@ it.layer(NodeServices.layer)("Linux browser secret path", (it) => { assert.isUndefined(yield* resolve(false, "win32")); yield* fileSystem.remove(staged); assert.isUndefined(yield* resolve(false)); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index 7e92334c957de..f82f2319baeb7 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -240,7 +240,7 @@ describe("safariAccessDenied", () => { yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); assert.isFalse(yield* safariAccessDenied(jar)); assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts index 3b33f872d124d..dc225b04bac72 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts @@ -39,7 +39,7 @@ it.effect("detects Safari access becoming available without reading or importing assert.isTrue(yield* Effect.promise(check)); yield* fs.remove(jar); assert.isFalse(yield* Effect.promise(check)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("recognizes access when cookies exist only in a named Safari profile", () => @@ -55,5 +55,5 @@ it.effect("recognizes access when cookies exist only in a named Safari profile", yield* fs.makeDirectory(directory, { recursive: true }); yield* fs.writeFileString(`${directory}/Cookies.binarycookies`, "not parsed"); assert.isTrue(yield* Effect.promise(check)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 720a57e64a9dc..9b2c3cb26c8f7 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -51,7 +51,7 @@ const run = ( E, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner >, -) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); +) => effect.pipe(Effect.provide(NodeServices.layer)); const writeCookieDatabase = (file: string, count: number) => Effect.sync(() => { diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 6bc2000f94a80..cae43abd48a8c 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -1,8 +1,11 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as NodeEvents from "node:events"; import { beforeEach, vi } from "vite-plus/test"; const { fromPartition, sessions } = vi.hoisted(() => ({ @@ -12,6 +15,8 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ { readonly clearCache: ReturnType; readonly clearStorageData: ReturnType; + readonly closeAllConnections: ReturnType; + readonly storagePath: string | null; readonly getUserAgent: ReturnType string>>; readonly setPermissionRequestHandler: ReturnType; readonly setPermissionCheckHandler: ReturnType; @@ -21,45 +26,126 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ >(), })); +const storage = vi.hoisted(() => ({ root: "" })); + const { fromWebContents } = vi.hoisted(() => ({ fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null), })); +const { appListeners } = vi.hoisted(() => ({ + appListeners: new Map void>(), +})); + vi.mock("electron", () => ({ + app: { + on: (eventName: string, listener: (...args: unknown[]) => void) => { + appListeners.set(eventName, listener); + }, + removeListener: (eventName: string) => { + appListeners.delete(eventName); + }, + }, BrowserWindow: { fromWebContents }, session: { fromPartition, }, })); +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as BrowserSession from "./BrowserSession.ts"; -const { showMessageBox } = vi.hoisted(() => ({ +const { showMessageBox, showErrorBox } = vi.hoisted(() => ({ showMessageBox: vi.fn< - (options: Electron.MessageBoxOptions, owner?: Electron.BrowserWindow) => number + ( + options: Electron.MessageBoxOptions, + owner?: Electron.BrowserWindow, + ) => number | Promise >(() => 1), + showErrorBox: vi.fn<(title: string, content: string) => void>(), })); const layerDialog = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.die("unused"), pickFiles: () => Effect.die("unused"), saveFile: () => Effect.die("unused"), showMessageBox: (options, owner) => - Effect.sync(() => ({ response: showMessageBox(options, owner), checkboxChecked: false })), - showErrorBox: () => Effect.void, + Effect.promise(async () => ({ + response: await showMessageBox(options, owner), + checkboxChecked: false, + })), + showErrorBox: (title, content) => Effect.sync(() => showErrorBox(title, content)), }); const layer = BrowserSession.layer.pipe( - Layer.provide(Layer.merge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), layerDialog)), + Layer.provide( + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, layerDialog, ElectronApp.layer), + ), ); +const selfSignedCertificate = { + fingerprint: "sha256/self-signed", + subjectName: "localhost", + issuerName: "localhost", + validStart: 1_735_776_000, + validExpiry: 1_807_056_000, +} as Electron.Certificate; + +/** Raises Electron's `certificate-error` for a preview guest in `browserSession`. */ +const certificateError = ( + browserSession: unknown, + input: { + readonly url: string; + readonly certificate?: Electron.Certificate; + readonly isMainFrame?: boolean; + }, +) => { + const listener = appListeners.get("certificate-error"); + assert.isDefined(listener, "preview sessions should handle certificate errors"); + const guest = Object.assign(new NodeEvents.EventEmitter(), { + session: browserSession, + hostWebContents: null, + isDestroyed: () => false, + }); + const event = { preventDefault: vi.fn() }; + const decision = Deferred.makeUnsafe(); + const callback = vi.fn((trusted: boolean) => { + Deferred.doneUnsafe(decision, Effect.succeed(trusted)); + }); + listener( + event, + guest, + input.url, + "net::ERR_CERT_AUTHORITY_INVALID", + input.certificate ?? selfSignedCertificate, + callback, + input.isMainFrame ?? true, + ); + return { guest, event, callback, trusted: Deferred.await(decision) }; +}; + +/** Answers the certificate prompt with its "Trust…" button. */ +const trustCertificateButton = (options: Electron.MessageBoxOptions): number => { + const index = (options.buttons ?? []).findIndex((label) => label.startsWith("Trust")); + assert.notStrictEqual(index, -1, "the certificate prompt should offer to trust"); + return index; +}; + describe("BrowserSession", () => { beforeEach(() => { sessions.clear(); + appListeners.clear(); + storage.root = ""; + showErrorBox.mockReset(); fromPartition.mockReset(); fromPartition.mockImplementation((partition: string) => { const browserSession = { clearCache: vi.fn(() => Promise.resolve()), clearStorageData: vi.fn(() => Promise.resolve()), + closeAllConnections: vi.fn(() => Promise.resolve()), + // Like Electron, only a `persist:` partition has storage on disk. + storagePath: + storage.root !== "" && partition.startsWith("persist:") + ? `${storage.root}/Partitions/${partition}` + : null, getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"), setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), @@ -130,6 +216,8 @@ describe("BrowserSession", () => { const browserSession = { clearCache: vi.fn(() => Promise.resolve()), clearStorageData: vi.fn(() => Promise.resolve()), + closeAllConnections: vi.fn(() => Promise.resolve()), + storagePath: null, getUserAgent: vi.fn(() => userAgent), setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), @@ -334,4 +422,236 @@ describe("BrowserSession", () => { assert.strictEqual(vi.mocked(other.clearStorageData).mock.calls.length, 0); }).pipe(Effect.provide(layer)), ); + + it.effect("asks before trusting an untrusted certificate and remembers the approval", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + + showMessageBox.mockReset(); + showMessageBox.mockImplementation(trustCertificateButton); + const first = certificateError(browserSession, { url: "https://localhost:18443/" }); + assert.strictEqual(first.event.preventDefault.mock.calls.length, 1); + assert.isTrue(yield* first.trusted); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + + // The approval covers this origin, port, and certificate in this profile. + const again = certificateError(browserSession, { url: "https://localhost:18443/other" }); + assert.isTrue(yield* again.trusted); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + // Its sockets reach the same TLS endpoint, and cannot ask on their own. + const socket = certificateError(browserSession, { + url: "wss://localhost:18443/hmr", + isMainFrame: false, + }); + assert.isTrue(yield* socket.trusted); + const otherSocket = certificateError(browserSession, { + url: "wss://localhost:18444/hmr", + isMainFrame: false, + }); + assert.isFalse(yield* otherSocket.trusted); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + + showMessageBox.mockReset(); + showMessageBox.mockReturnValue(1); + const otherPort = certificateError(browserSession, { url: "https://localhost:18444/" }); + assert.isFalse(yield* otherPort.trusted); + const renewed = certificateError(browserSession, { + url: "https://localhost:18443/", + certificate: { ...selfSignedCertificate, fingerprint: "sha256/renewed" }, + }); + assert.isFalse(yield* renewed.trusted); + assert.strictEqual(showMessageBox.mock.calls.length, 2); + + // A Session that is not a preview profile keeps Electron's denial. + const appSession = certificateError({}, { url: "https://localhost:18443/" }); + assert.strictEqual(appSession.event.preventDefault.mock.calls.length, 0); + assert.strictEqual(appSession.callback.mock.calls.length, 0); + }).pipe(Effect.provide(layer)), + ); + + it.effect("revokes certificate approvals when cookies are cleared, not the cache", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + const url = "https://localhost:18443/"; + + showMessageBox.mockReset(); + showMessageBox.mockImplementation(trustCertificateButton); + assert.isTrue(yield* certificateError(browserSession, { url }).trusted); + + yield* browserSessions.clearCache([partition]); + assert.isTrue(yield* certificateError(browserSession, { url }).trusted); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + + // A failed storage clear still forgets the approvals the user asked to clear. + browserSession!.clearStorageData.mockRejectedValueOnce(new Error("disk busy")); + const failure = yield* browserSessions.clearCookies([partition]).pipe(Effect.flip); + assert.strictEqual(failure._tag, "BrowserSessionStorageClearError"); + showMessageBox.mockReset(); + showMessageBox.mockReturnValue(1); + assert.isFalse(yield* certificateError(browserSession, { url }).trusted); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + }).pipe(Effect.provide(layer)), + ); + + it.effect("remembers approvals across restarts, but not incognito ones", () => + Effect.gen(function* () { + storage.root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped(); + const url = "https://localhost:18443/"; + const approve = Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const persistent = yield* browserSessions.getPartition("scope-a"); + const incognito = yield* browserSessions.getPartition("scope-a", false); + yield* browserSessions.getSession("scope-a"); + yield* browserSessions.getSession("scope-a", false); + showMessageBox.mockReset(); + showMessageBox.mockImplementation(trustCertificateButton); + assert.isTrue(yield* certificateError(sessions.get(persistent), { url }).trusted); + assert.isTrue(yield* certificateError(sessions.get(incognito), { url }).trusted); + return { persistent, incognito }; + }); + const { persistent, incognito } = yield* approve.pipe(Effect.provide(layer)); + + // A restarted app gets fresh Session objects and reads approvals from disk. + sessions.clear(); + appListeners.clear(); + yield* Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + yield* browserSessions.getSession("scope-a"); + yield* browserSessions.getSession("scope-a", false); + showMessageBox.mockReset(); + showMessageBox.mockReturnValue(1); + assert.isTrue(yield* certificateError(sessions.get(persistent), { url }).trusted); + assert.strictEqual(showMessageBox.mock.calls.length, 0); + assert.isFalse(yield* certificateError(sessions.get(incognito), { url }).trusted); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("an answer to a prompt opened before cookies were cleared does not trust", () => + Effect.gen(function* () { + storage.root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped(); + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + const url = "https://localhost:18443/"; + + const asked = yield* Deferred.make(); + let answer = () => {}; + const answered = new Promise((resolve) => { + answer = resolve; + }); + showMessageBox.mockReset(); + showMessageBox.mockImplementation((options) => { + Deferred.doneUnsafe(asked, Effect.void); + return answered.then(() => trustCertificateButton(options)); + }); + const pending = certificateError(browserSession, { url }); + yield* Deferred.await(asked); + yield* browserSessions.clearCookies([partition]); + answer(); + assert.isFalse(yield* pending.trusted); + + showMessageBox.mockReset(); + showMessageBox.mockReturnValue(1); + assert.isFalse(yield* certificateError(browserSession, { url }).trusted); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + assert.isFalse( + yield* (yield* FileSystem.FileSystem).exists( + `${browserSession!.storagePath}/t3code-certificate-approvals.json`, + ), + ); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, NodeServices.layer))), + ); + + it.effect("a tab that closes or navigates away dismisses its certificate prompt", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + const url = "https://localhost:18443/"; + + for (const leave of [ + (guest: NodeEvents.EventEmitter) => + guest.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }), + (guest: NodeEvents.EventEmitter) => guest.emit("destroyed"), + ]) { + showMessageBox.mockReset(); + // Answering "Trust" as the tab leaves must not approve the certificate. + showMessageBox.mockImplementation( + (options) => + new Promise((resolve) => + options.signal?.addEventListener("abort", () => + resolve(trustCertificateButton(options)), + ), + ), + ); + const pending = certificateError(browserSession, { url }); + pending.guest.emit("did-start-navigation", { isMainFrame: true, isSameDocument: true }); + leave(pending.guest); + assert.isFalse(yield* pending.trusted); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + } + }).pipe(Effect.provide(layer)), + ); + + it.effect("does not trust a certificate whose approval cannot be saved", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + // A file where the profile directory should be makes every write fail. + const root = yield* fileSystem.makeTempDirectoryScoped(); + yield* fileSystem.writeFileString(`${root}/Partitions`, ""); + storage.root = root; + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + const url = "https://localhost:18443/"; + + showMessageBox.mockReset(); + showMessageBox.mockImplementation(trustCertificateButton); + assert.isFalse(yield* certificateError(browserSession, { url }).trusted); + assert.strictEqual(showErrorBox.mock.calls.length, 1); + // Nothing was trusted for this run either, so the next visit asks again. + showMessageBox.mockReset(); + showMessageBox.mockReturnValue(1); + assert.isFalse(yield* certificateError(browserSession, { url }).trusted); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, NodeServices.layer))), + ); + + it.effect("clearing cookies deletes a saved approval even when none are loaded", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + storage.root = yield* fileSystem.makeTempDirectoryScoped(); + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + // What a clear whose removal failed leaves behind: memory forgot, the file did not. + const file = `${browserSession!.storagePath}/t3code-certificate-approvals.json`; + yield* fileSystem.makeDirectory(browserSession!.storagePath!, { recursive: true }); + yield* fileSystem.writeFileString( + file, + JSON.stringify([ + { + origin: "https://localhost:18443", + fingerprint: selfSignedCertificate.fingerprint, + error: "net::ERR_CERT_AUTHORITY_INVALID", + }, + ]), + ); + + yield* browserSessions.clearCookies([partition]); + assert.isFalse(yield* fileSystem.exists(file)); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, NodeServices.layer))), + ); }); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index b2523ba62b973..1400f2c9cbe09 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -1,14 +1,21 @@ -import type { Session, WebContents } from "electron"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { Certificate, Session, WebContents } from "electron"; import { BrowserWindow, session } from "electron"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; @@ -73,6 +80,64 @@ const permissionOrigin = (rawUrl: string | undefined): string | null => { } }; +/** + * Certificate errors a user may accept for a preview page, with the reason the + * prompt shows. Every other error keeps Electron's denial. + */ +const OVERRIDABLE_CERTIFICATE_ERRORS: ReadonlyMap = new Map([ + ["net::ERR_CERT_AUTHORITY_INVALID", "No trusted authority issued this certificate."], + ["net::ERR_CERT_COMMON_NAME_INVALID", "This certificate is not for this address."], + ["net::ERR_CERT_DATE_INVALID", "This certificate has expired or is not valid yet."], +]); + +/** + * A `wss:` socket reaches the same TLS endpoint as the page, so it shares the + * `https:` origin. It must: a socket is never a main frame, so it could not be + * approved on its own, and a page's dev-server socket would stay blocked. + */ +const certificateOrigin = (rawUrl: string): string | null => { + try { + const url = new URL(rawUrl); + return url.protocol === "https:" || url.protocol === "wss:" ? `https://${url.host}` : null; + } catch { + return null; + } +}; + +const CertificateApproval = Schema.Struct({ + origin: Schema.String, + fingerprint: Schema.String, + error: Schema.String, +}); +type CertificateApproval = typeof CertificateApproval.Type; +const CertificateApprovalsJson = Schema.fromJsonString(Schema.Array(CertificateApproval)); +const decodeCertificateApprovals = Schema.decodeEffect(CertificateApprovalsJson); +const encodeCertificateApprovals = Schema.encodeEffect(CertificateApprovalsJson); + +const certificateApprovalKey = ({ origin, fingerprint, error }: CertificateApproval): string => + JSON.stringify([origin, fingerprint, error]); + +/** Kept beside the profile's cookies, so it lives and dies with them. */ +const CERTIFICATE_APPROVALS_FILE = "t3code-certificate-approvals.json"; + +/** Server-chosen certificate names must not add lines or flood the prompt. */ +const promptCertificateName = (name: string): string => { + const flat = name.replace(/\s+/g, " ").trim(); + return flat.length > 120 ? `${flat.slice(0, 120)}…` : flat; +}; + +const promptCertificateDate = (seconds: number): string => + DateTime.formatIsoDateUtc(DateTime.makeUnsafe(seconds * 1000)); + +/** The certificates a user accepted in one preview Session. */ +interface CertificateTrust { + /** `null` for an incognito Session, whose approvals end with the process. */ + readonly file: string | null; + readonly approvals: Map; + /** Bumped by each revocation, so an answer to an older prompt cannot restore trust. */ + revocations: number; +} + export class BrowserSessionPartitionDerivationError extends Schema.TaggedError()( "BrowserSessionPartitionDerivationError", { @@ -154,6 +219,10 @@ export class BrowserSession extends Context.Service< readonly clearCookies: ( partitions?: ReadonlyArray, ) => Effect.Effect; + /** For cookies cleared outside `clearCookies`, such as over a tab's CDP session. */ + readonly cookiesCleared: ( + browserSession: Session, + ) => Effect.Effect; readonly clearCache: ( partitions?: ReadonlyArray, ) => Effect.Effect; @@ -190,7 +259,11 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined /** @public */ export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Digest.Digest; + const electronApp = yield* ElectronApp.ElectronApp; const electronDialog = yield* ElectronDialog.ElectronDialog; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; const runFork = Effect.runForkWith(yield* Effect.context()); // One prompt at a time, so a page cannot stack dialogs by looping a deep link. let permissionPromptOpen = false; @@ -257,6 +330,229 @@ export const make = Effect.gen(function* BrowserSessionMake() { (answer) => callback(answer === true), ); }; + + const certificateTrust = new WeakMap(); + const writeCertificateApprovals = yield* Semaphore.make(1); + const loadCertificateTrust = Effect.fn("BrowserSession.loadCertificateTrust")(function* ( + browserSession: Session, + ) { + const file = + browserSession.storagePath == null + ? null + : path.join(browserSession.storagePath, CERTIFICATE_APPROVALS_FILE); + const approvals: ReadonlyArray = + file === null + ? [] + : yield* fileSystem.readFileString(file).pipe( + Effect.flatMap(decodeCertificateApprovals), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed([]) : Effect.fail(error), + }), + Effect.catch((cause) => + Effect.logWarning("Ignored saved certificate approvals.", { file, cause }).pipe( + Effect.as([]), + ), + ), + ); + const trust: CertificateTrust = { + file, + approvals: new Map(approvals.map((approval) => [certificateApprovalKey(approval), approval])), + revocations: 0, + }; + return trust; + }); + const writeApprovalsFile = Effect.fn("BrowserSession.writeApprovalsFile")(function* ( + file: string, + approvals: ReadonlyMap, + ) { + if (approvals.size === 0) return yield* fileSystem.remove(file, { force: true }); + yield* fileSystem.makeDirectory(path.dirname(file), { recursive: true }); + yield* fileSystem.writeFileString( + file, + yield* encodeCertificateApprovals([...approvals.values()]), + ); + }); + /** + * Saved before it is trusted, so a failed write cannot leave an approval that + * silently ends at restart. Writes share one lock, so a slower write cannot + * restore stale data. + */ + const rememberCertificate = Effect.fn("BrowserSession.rememberCertificate")(function* ( + trust: CertificateTrust, + key: string, + approval: CertificateApproval, + revocations: number, + ) { + if (trust.revocations !== revocations) return false; + if (trust.file !== null) { + yield* writeApprovalsFile(trust.file, new Map(trust.approvals).set(key, approval)); + // A revocation during the write queues its own rewrite behind this one. + if (trust.revocations !== revocations) return false; + } + trust.approvals.set(key, approval); + return true; + }, writeCertificateApprovals.withPermits(1)); + /** + * Rewrites the file from memory. Clearing cookies runs this even when memory + * is already empty, so retrying after a failed removal still deletes the file. + */ + const saveCertificateApprovals = Effect.fn("BrowserSession.saveCertificateApprovals")(function* ( + trust: CertificateTrust, + ) { + if (trust.file !== null) yield* writeApprovalsFile(trust.file, trust.approvals); + }, writeCertificateApprovals.withPermits(1)); + + // One prompt at a time; tabs that hit the same certificate wait on it. + let certificatePrompt: { + readonly trust: CertificateTrust; + readonly key: string; + readonly callbacks: Array<(trusted: boolean) => void>; + } | null = null; + const confirmCertificate = ( + contents: WebContents, + trust: CertificateTrust, + approval: CertificateApproval, + certificate: Certificate, + reason: string, + callback: (trusted: boolean) => void, + ): void => { + const key = certificateApprovalKey(approval); + if (certificatePrompt !== null) { + if (certificatePrompt.trust === trust && certificatePrompt.key === key) { + certificatePrompt.callbacks.push(callback); + } else { + callback(false); + } + return; + } + const prompt = { trust, key, callbacks: [callback] }; + certificatePrompt = prompt; + // A prompt for a tab that closed or moved on would approve a page nobody is waiting for. + const dismiss = new AbortController(); + const abandon = () => dismiss.abort(); + const navigated = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) abandon(); + }; + contents.once("destroyed", abandon); + contents.on("did-start-navigation", navigated); + const settle = (trusted: boolean) => + Effect.sync(() => { + certificatePrompt = null; + if (!contents.isDestroyed()) { + contents.off("destroyed", abandon); + contents.off("did-start-navigation", navigated); + } + for (const waiter of prompt.callbacks) waiter(trusted); + }); + const host = new URL(approval.origin).host; + const revocations = trust.revocations; + runFork( + electronDialog + .showMessageBox( + { + type: "warning", + buttons: ["Trust and Remember", "Cancel"], + defaultId: 1, + cancelId: 1, + signal: dismiss.signal, + message: `${host} has an untrusted certificate`, + detail: [ + reason, + "", + `Issued to: ${promptCertificateName(certificate.subjectName)}`, + `Issued by: ${promptCertificateName(certificate.issuerName)}`, + `Valid: ${promptCertificateDate(certificate.validStart)} – ${promptCertificateDate(certificate.validExpiry)}`, + `Fingerprint: ${approval.fingerprint}`, + "", + "Trust it only if you know this certificate belongs to the site. Clearing this browser profile's cookies forgets it.", + ].join("\n"), + }, + // In the reporter's Windows test, a box owned by the host window + // blocked all mouse input, its own buttons included, until it closed. + platform === "win32" ? undefined : previewHostWindow(contents), + ) + .pipe( + Effect.flatMap(({ response }) => + response === 0 && !dismiss.signal.aborted + ? rememberCertificate(trust, key, approval, revocations).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to save a certificate approval.", { cause }).pipe( + Effect.andThen( + electronDialog.showErrorBox( + `Could not trust the certificate for ${host}`, + "T3 Code could not save this approval, so the page stays blocked. Check that the disk is not full and its data folder is writable, then try again.", + ), + ), + Effect.as(false), + ), + ), + ) + : Effect.succeed(false), + ), + Effect.orElseSucceed(() => false), + Effect.flatMap(settle), + Effect.onInterrupt(() => settle(false)), + ), + ); + }; + yield* electronApp.on( + "certificate-error", + ( + event: Electron.Event, + contents: WebContents, + url: string, + error: string, + certificate: Certificate, + callback: (trusted: boolean) => void, + isMainFrame: boolean, + ) => { + if (contents.isDestroyed()) return; + // Sessions this service did not create keep Electron's denial. + const trust = certificateTrust.get(contents.session); + const origin = certificateOrigin(url); + const reason = OVERRIDABLE_CERTIFICATE_ERRORS.get(error); + if (trust === undefined || origin === null || reason === undefined) return; + const approval = { origin, fingerprint: certificate.fingerprint, error }; + event.preventDefault(); + if (trust.approvals.has(certificateApprovalKey(approval))) { + callback(true); + return; + } + // Only a page navigation asks; a subresource the user has not accepted stays blocked. + if (!isMainFrame) { + callback(false); + return; + } + confirmCertificate(contents, trust, approval, certificate, reason, callback); + }, + ); + + /** Clearing a profile's cookies also forgets the certificates its user trusted. */ + const revokeCertificates = Effect.fn("BrowserSession.revokeCertificates")(function* ( + partition: string, + browserSession: Session, + ) { + const trust = certificateTrust.get(browserSession); + if (trust === undefined) return; + const hadApprovals = trust.approvals.size > 0; + trust.revocations += 1; + trust.approvals.clear(); + const saved = yield* saveCertificateApprovals(trust).pipe(Effect.result); + // An open connection already passed its certificate check. + if (hadApprovals) { + yield* Effect.tryPromise({ + try: () => browserSession.closeAllConnections(), + catch: (cause) => new BrowserSessionStorageClearError({ partition, cause }), + }); + } + if (Result.isFailure(saved)) { + return yield* new BrowserSessionStorageClearError({ partition, cause: saved.failure }); + } + }); + const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( @@ -361,9 +657,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { }); }, ); - const next = new Map(sessions); - next.set(partition, browserSession); - return [browserSession, next] as const; + return browserSession; }, catch: (cause) => new BrowserSessionCreationError({ @@ -371,7 +665,18 @@ export const make = Effect.gen(function* BrowserSessionMake() { partition, cause, }), - }); + }).pipe( + Effect.flatMap((browserSession) => + loadCertificateTrust(browserSession).pipe( + Effect.map((trust) => { + certificateTrust.set(browserSession, trust); + const next = new Map(sessions); + next.set(partition, browserSession); + return [browserSession, next] as const; + }), + ), + ), + ); }); }); @@ -386,20 +691,37 @@ export const make = Effect.gen(function* BrowserSessionMake() { yield* Effect.forEach( selectSessions(sessions, partitions), ([partition, browserSession]) => - Effect.tryPromise({ - try: () => - browserSession.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "serviceworkers", "cachestorage"], - }), - catch: (cause) => - new BrowserSessionStorageClearError({ - partition, - cause, - }), + Effect.gen(function* () { + const cleared = yield* Effect.tryPromise({ + try: () => + browserSession.clearStorageData({ + storages: [ + "cookies", + "localstorage", + "indexdb", + "serviceworkers", + "cachestorage", + ], + }), + catch: (cause) => + new BrowserSessionStorageClearError({ + partition, + cause, + }), + }).pipe(Effect.result); + // Approvals go even when the storage clear fails, since the user asked to forget them. + yield* revokeCertificates(partition, browserSession); + if (Result.isFailure(cleared)) return yield* cleared.failure; }), { concurrency: "unbounded", discard: true }, ); }), + cookiesCleared: Effect.fn("BrowserSession.cookiesCleared")(function* (browserSession) { + const sessions = yield* SynchronizedRef.get(sessionsRef); + for (const [partition, candidate] of sessions) { + if (candidate === browserSession) yield* revokeCertificates(partition, browserSession); + } + }), clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.forEach( diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 6e657ba5fc4d7..ccc2b868720b4 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -16,6 +16,7 @@ const makeTarget = ( title: () => "Fixture", userAgent: () => "Electron", setDownloadDirectory, + receivesScreencast: () => true, }); const makeRelay = (target: CdpRelayTarget, write: (raw: string) => void) => diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 94a78f47db686..658e1cf70623d 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -9,6 +9,8 @@ export interface CdpRelayTarget { owner?: string, ) => Promise; readonly sessionDetached?: (owner: string) => void; + /** Whether a page session, by owner as `send` names it, gets the page's screencast frames. */ + readonly receivesScreencast: (owner: string | undefined) => boolean; /** The page's own CDP target id; Playwright keys the main frame by it. */ readonly targetId: () => Promise; readonly url: () => string; @@ -260,8 +262,15 @@ export function createCdpRelayConnection( send({ method, params, sessionId }); return; } - for (const [id, kind] of sessions) - if (kind === "page") send({ method, params, sessionId: id }); + for (const [id, kind] of sessions) { + if (kind !== "page") continue; + if ( + method === "Page.screencastFrame" && + !target.receivesScreencast(id === PAGE_SESSION_ID ? undefined : id) + ) + continue; + send({ method, params, sessionId: id }); + } }, }; } diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 94809f0d10cfa..393e4cccccf9c 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -16,6 +16,15 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeCdpReply = Schema.decodeUnknownSync( Schema.fromJsonString(Schema.Struct({ id: Schema.Number })), ); +const decodeCdpMessage = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.optional(Schema.Number), + method: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + }), + ), +); const key = { threadId: "thread-1", tabId: "tab-1" }; /** Sends a page command whose id the fake debugger records as it receives it. */ const command = ( @@ -43,6 +52,8 @@ const makeDebuggee = ( const pending: Array<() => void> = []; const focusChanges: Array<() => void> = []; const sent: Array = []; + const methods: Array = []; + const rejects: Array<() => void> = []; const sendWaiters: Array<() => void> = []; const focusEmulation: Array = []; const debuggee = Object.assign(emitter, { @@ -67,6 +78,8 @@ const makeDebuggee = ( return; } sent.push(params?.id); + methods.push(method); + rejects.push(() => reject(new Error("Refused"))); pending.push(() => resolve({ method })); sendWaiters.shift()?.(); }), @@ -94,6 +107,10 @@ const makeDebuggee = ( webContents.emit("did-fail-load", {}, code, "Failed", "http://localhost/", true), /** Ids of the commands the debugger received, in order. */ sent, + /** Methods of the same commands, in order. */ + methods, + /** Refuses the pending command the debugger received with this id. */ + refuse: (id: number) => rejects[sent.indexOf(id)]?.(), focusEmulation, nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), @@ -116,16 +133,24 @@ const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], coun /** * Ids of the replies the host sends, in order. A screenshot's reply leaves after its turn * has passed on, so by then the next waiting screenshot, if any, has been sent. Subscribe - * before sending commands: a new subscriber releases the tab's relay. + * before sending commands: a new subscriber releases the tab's relay. Screencast frames + * go to `frames` by the session they were sent to. */ -const watchReplies = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => +const watchReplies = ( + host: DesktopBrowserHost.DesktopBrowserHost["Service"], + frames: Array = [], +) => Effect.gen(function* () { const replies = yield* Queue.unbounded(); yield* host.events.pipe( Stream.map((line) => decodeEvent(new TextDecoder().decode(line))), - Stream.runForEach((event) => - event.type === "cdp" ? Queue.offer(replies, decodeCdpReply(event.message).id) : Effect.void, - ), + Stream.runForEach((event) => { + if (event.type !== "cdp") return Effect.void; + const { id, method, sessionId } = decodeCdpMessage(event.message); + if (id !== undefined) return Queue.offer(replies, id); + if (method === "Page.screencastFrame") frames.push(sessionId); + return Effect.void; + }), Effect.forkScoped, ); yield* Effect.yieldNow; @@ -283,7 +308,7 @@ describe("DesktopBrowserHost", () => { expect(reply).toMatchObject({ type: "cdp" }); expect(decodeCdpReply((reply as { message: string }).message).id).toBe(2); - }).pipe(Effect.scoped), + }), ); it.effect("sends a page one screenshot at a time", () => @@ -302,7 +327,7 @@ describe("DesktopBrowserHost", () => { debuggee.releaseFirst(); expect(yield* Queue.take(replies)).toBe(1); expect(debuggee.sent).toEqual([1, 3, 2]); - }).pipe(Effect.scoped), + }), ); it.effect("stops waiting on a screenshot the page navigated away from", () => @@ -327,7 +352,7 @@ describe("DesktopBrowserHost", () => { debuggee.releaseFirst(); expect(yield* Queue.take(replies)).toBe(2); expect(debuggee.sent).toEqual([1, 2, 3]); - }).pipe(Effect.scoped), + }), ); it.effect("drops screenshots a released relay left waiting", () => @@ -399,7 +424,7 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(1); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle once Chromium can no longer answer its pending command", () => @@ -587,7 +612,7 @@ describe("DesktopBrowserHost", () => { yield* turn(false); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false, true]); - }).pipe(Effect.scoped), + }), ); it.effect("paints for a screenshot queued behind one its viewer abandoned mid-capture", () => @@ -625,7 +650,7 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(4); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("stops painting for a queued screenshot once its session detaches", () => @@ -664,7 +689,7 @@ describe("DesktopBrowserHost", () => { expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("never sends a screenshot whose session detached while focus was changing", () => @@ -688,7 +713,7 @@ describe("DesktopBrowserHost", () => { debuggee.settleFocus(); yield* settle; expect(debuggee.sent).toEqual([]); - }).pipe(Effect.scoped), + }), ); it.effect("holds no focus for a screenshot still queued when the agent's turn ends", () => @@ -718,7 +743,7 @@ describe("DesktopBrowserHost", () => { debuggee.release(); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle when Chromium refuses a viewer's screencast", () => @@ -739,7 +764,7 @@ describe("DesktopBrowserHost", () => { expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle after a person's input through a viewer that then closed", () => @@ -768,7 +793,7 @@ describe("DesktopBrowserHost", () => { yield* settle; yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("keeps a page's screencast when a child session's screencast is refused", () => @@ -790,7 +815,7 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(2); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true]); - }).pipe(Effect.scoped), + }), ); it.effect("ignores a screencast that arrives on a session after it detached", () => @@ -816,7 +841,7 @@ describe("DesktopBrowserHost", () => { yield* settle; yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle once the session waiting on its command detaches", () => @@ -843,7 +868,7 @@ describe("DesktopBrowserHost", () => { }); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("never sends a screenshot queued by a session that has since detached", () => @@ -874,7 +899,7 @@ describe("DesktopBrowserHost", () => { yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("keeps a streamed guest focused until its screencast ends", () => @@ -935,7 +960,7 @@ describe("DesktopBrowserHost", () => { }); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("paints a hidden guest only while the screenshot it is taking is pending", () => @@ -961,7 +986,7 @@ describe("DesktopBrowserHost", () => { debuggee.release(); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("stops painting for a screenshot Chromium never answers once its document goes", () => @@ -1006,7 +1031,7 @@ describe("DesktopBrowserHost", () => { host.detach(key); yield* settle; expect(painting).toEqual([true, false, true, false, true, false, true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("paints a streamed guest until its last screencast ends", () => @@ -1037,7 +1062,176 @@ describe("DesktopBrowserHost", () => { }); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), + ); + + it.effect("keeps a guest's one screencast running until its last streaming session stops", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const frames: Array = []; + const replies = yield* watchReplies(host, frames); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const nativeStops = () => + debuggee.methods.filter((method) => method === "Page.stopScreencast").length; + + // A viewer and a recording share Chromium's single screencast for the guest. + yield* raw({ id: 0, method: "Target.setAutoAttach", params: { autoAttach: true } }); + expect(yield* Queue.take(replies)).toBe(0); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + debuggee.release(); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + expect(nativeStops(), "stopped the screencast another session still streams").toBe(0); + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 }); + yield* settle; + expect(frames, "sent a stopped or idle session the screencast's frames").toEqual([ + "t3-preview-page-2", + ]); + + yield* raw({ id: 6, method: "Page.stopScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + expect(nativeStops()).toBe(1); + }), + ); + + it.effect("keeps a session's screencast when another of its starts is refused", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const frames: Array = []; + const replies = yield* watchReplies(host, frames); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 0, method: "Target.setAutoAttach", params: { autoAttach: true } }); + expect(yield* Queue.take(replies)).toBe(0); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ + id: 2, + method: "Page.startScreencast", + params: { id: 2 }, + sessionId: "t3-preview-page-1", + }); + yield* raw({ + id: 3, + method: "Page.startScreencast", + params: { id: 3 }, + sessionId: "t3-preview-page-1", + }); + yield* settle; + debuggee.refuse(2); + debuggee.release(); + expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([2, 3]); + yield* settle; + expect(debuggee.methods).not.toContain("Page.stopScreencast"); + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 }); + yield* settle; + expect(frames).toContain("t3-preview-page-1"); + + // A refused replacement leaves the session's running stream, shared with another session. + yield* raw({ id: 4, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(4); + yield* raw({ + id: 5, + method: "Page.startScreencast", + params: { id: 5 }, + sessionId: "t3-preview-page-2", + }); + yield* raw({ + id: 6, + method: "Page.startScreencast", + params: { id: 6 }, + sessionId: "t3-preview-page-1", + }); + yield* settle; + debuggee.refuse(6); + debuggee.release(); + expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([5, 6]); + yield* settle; + frames.length = 0; + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 2 }); + yield* settle; + expect(frames).toEqual(["t3-preview-page-1", "t3-preview-page-2"]); + }), + ); + + it.effect("stops a guest's screencast once its last streaming session detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const nativeStops = () => + debuggee.methods.filter((method) => method === "Page.stopScreencast").length; + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + debuggee.release(); + // Streamers go without stopping, such as viewers that closed. + yield* raw({ + id: 5, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* settle; + expect(nativeStops(), "stopped the screencast another session still streams").toBe(0); + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + const answered = []; + for (let reply = 0; reply < 4; reply++) answered.push(yield* Queue.take(replies)); + expect(answered.toSorted()).toEqual([3, 4, 5, 6]); + yield* settle; + expect(nativeStops()).toBe(1); + }), + ); + + it.effect("stops a screencast whose start lands after its session detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + // The start waits on focus emulation while the viewer closes. + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + debuggee.settleFocus(); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(debuggee.methods.at(-1), "left the late screencast running").toBe( + "Page.stopScreencast", + ); + }), ); it.effect("saves a server tab's download under its CDP guid where the server asked", () => @@ -1072,7 +1266,7 @@ describe("DesktopBrowserHost", () => { expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(true); expect(paths).toEqual(["/srv/downloads/guid-1"]); expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(false); - }).pipe(Effect.scoped), + }), ); it.effect("tells a download the person clicked from one the agent's input started", () => @@ -1170,6 +1364,6 @@ describe("DesktopBrowserHost", () => { expect(JSON.parse((next as { message: string }).message)).toMatchObject({ method: "Page.loadEventFired", }); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 2cda717a891b7..e5f43634d204a 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -40,6 +40,8 @@ export interface DesktopBrowserTabKey { export interface DesktopBrowserTabDebugger { readonly webContents: Electron.WebContents; readonly debugger: Electron.Debugger; + /** Runs after the server clears the tab's cookies, so state kept beside them goes too. */ + readonly cookiesCleared?: () => Promise; } const keyOf = ({ threadId, tabId }: DesktopBrowserTabKey) => `${threadId}\u0000${tabId}`; @@ -73,8 +75,8 @@ interface AttachedTab { */ agentUsed: boolean; focusReleasing: boolean; - /** The sessions streaming the guest; one stopping leaves the others' screencasts running. */ - readonly screencasts: Set; + /** The sessions sharing the guest's one Chromium screencast; it stops with the last of them. */ + readonly screencasts: Map; readonly onMessage: ( event: Electron.Event, method: string, @@ -287,9 +289,15 @@ export const make = Effect.gen(function* () { dropFocusHolds(tab); }; + /** An owner that leaves without stopping would otherwise keep the screencast running unseen. */ + const leaveScreencast = (tab: AttachedTab, owner: string | undefined) => { + if (!tab.screencasts.delete(owner) || tab.screencasts.size > 0) return; + void tab.debuggee.debugger.sendCommand("Page.stopScreencast").catch(() => {}); + }; + /** A detached session, such as a closed viewer, waits on nothing it sent. */ const dropSessionHolds = (tab: AttachedTab, owner: string) => { - tab.screencasts.delete(owner); + leaveScreencast(tab, owner); for (const [pending, holder] of tab.focusPending) { if (holder === owner) tab.focusPending.delete(pending); } @@ -315,9 +323,12 @@ export const make = Effect.gen(function* () { const relayFor = (tab: AttachedTab) => { if (tab.relay) return tab.relay; - const { webContents, debugger: debuggee } = tab.debuggee; - const send = (method: string, params: Record, sessionId?: string) => - sendPageCommand(webContents, debuggee, method, params, sessionId); + const { webContents, debugger: debuggee, cookiesCleared } = tab.debuggee; + const send = async (method: string, params: Record, sessionId?: string) => { + const result = await sendPageCommand(webContents, debuggee, method, params, sessionId); + if (method === "Network.clearBrowserCookies") await cookiesCleared?.(); + return result; + }; const detachedOwners = new Set(); /** Viewers and recordings stream; the agent's own sessions never do. */ const streamingOwners = new Set(); @@ -325,13 +336,19 @@ export const make = Effect.gen(function* () { { send: (method, params, sessionId, owner) => { // A child session's screencast is not the page's, such as a late one on a detached session. - if (sessionId === undefined && method === "Page.startScreencast") { - tab.screencasts.add(owner); + const stream = + sessionId === undefined && method === "Page.startScreencast" + ? (tab.screencasts.get(owner) ?? { started: false, pending: 0 }) + : undefined; + if (stream) { + stream.pending += 1; + tab.screencasts.set(owner, stream); if (owner !== undefined) streamingOwners.add(owner); syncPainting(tab); } else if (sessionId === undefined && method === "Page.stopScreencast") { tab.screencasts.delete(owner); syncPainting(tab); + if (tab.screencasts.size > 0) return Promise.resolve({}); } // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. if ( @@ -360,19 +377,42 @@ export const make = Effect.gen(function* () { held ? holdFocus(tab, owner, () => forward(current)) : forward(current), ) : method === "Page.startScreencast" - ? 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, owner, forward).then( + (result) => { + if (stream) { + stream.pending -= 1; + stream.started = true; + // A detach's stop can overtake a start still waiting on focus; nobody owns it now. + if (tab.screencasts.get(owner) !== stream && tab.screencasts.size === 0) { + void debuggee.sendCommand("Page.stopScreencast").catch(() => {}); + } + } + return result; + }, + (cause: unknown) => { + if (stream) stream.pending -= 1; + // A viewer can outlive a refused start; its running or pending stream still holds it. + if ( + stream && + !stream.started && + stream.pending === 0 && + tab.relay === relay && + tab.screencasts.get(owner) === stream + ) { + leaveScreencast(tab, owner); + } + releaseFocusWhenIdle(tab); + syncPainting(tab); + throw cause; + }, + ) : holdFocus(tab, owner, forward); }, sessionDetached: (owner) => { detachedOwners.add(owner); dropSessionHolds(tab, owner); }, + receivesScreencast: (owner) => tab.screencasts.has(owner), targetId: () => debuggee .sendCommand("Target.getTargetInfo") @@ -458,7 +498,7 @@ export const make = Effect.gen(function* () { painting: false, agentUsed: false, focusReleasing: false, - screencasts: new Set(), + screencasts: new Map(), onMessage: (_event, method, params, sessionId) => { if (method === "Page.downloadWillBegin") { const guid = (params as { guid?: unknown } | undefined)?.guid; diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 09d4eff6d2013..0a9f4cb85c008 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -6,6 +6,9 @@ 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"; +// Sent to the hosting element (ipc-message). The web app mirrors this +// literal in HostedBrowserWebview because it cannot import from the desktop app. +export const POINTER_DOWN_HOST_CHANNEL = "preview:pointer-down"; 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"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index a178edeb9753f..b344b1ac93b2b 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -205,6 +205,7 @@ const layerBrowserSession = Layer.succeed( isPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getSession: () => Effect.succeed(previewSession as unknown as Electron.Session), clearCookies: () => Effect.void, + cookiesCleared: () => Effect.void, clearCache: () => Effect.void, }), ); @@ -670,7 +671,7 @@ describe("PreviewManager", () => { if (existing) { expect(yield* fs.readFileString(`${downloads}/report.txt`)).toBe("existing download"); } - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); effectIt.effect.each([ @@ -819,7 +820,7 @@ describe("PreviewManager", () => { ["linux", false], ["win32", false], ] as const)( - "lets the page and its iframes claim the find shortcut before the host opens find on %s", + "lets the page and its iframes claim the find and zoom shortcuts before the host acts on %s", ([platform, usesMeta]) => withManager( (manager) => @@ -837,8 +838,23 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_page_find", 42); yield* manager.setForwardedShortcuts([ { command: "preview.find", shortcut: parseKeybindingShortcut("mod+f")! }, + { command: "preview.zoomIn", shortcut: parseKeybindingShortcut("mod+=")! }, ]); const beforeInput = preview.listeners.get("before-input-event")!; + const keepsZoom = vi.fn(); + beforeInput( + { preventDefault: keepsZoom } as never, + { + type: "keyDown", + key: "=", + code: "Equal", + meta: usesMeta, + control: !usesMeta, + shift: false, + alt: false, + } as never, + ); + expect(keepsZoom).not.toHaveBeenCalled(); const findKey = { type: "keyDown", key: "f", @@ -2055,7 +2071,7 @@ describe("PreviewManager", () => { expect(yield* nextType).toBe("attached"); yield* manager.closeTab("tab_1"); expect(yield* nextType).toBe("detached"); - }).pipe(Effect.provide(managerLayer()), Effect.scoped), + }).pipe(Effect.provide(managerLayer())), ); effectIt.effect( @@ -2114,7 +2130,7 @@ describe("PreviewManager", () => { expect(yield* Queue.take(capturing)).toBe(true); screenshot.resolve({ data: "" }); expect(yield* Queue.take(capturing)).toBe(false); - }).pipe(Effect.provide(managerLayer()), Effect.scoped), + }).pipe(Effect.provide(managerLayer())), ); effectIt.effect("reports early and subsequent load failures for the current guest", () => @@ -2205,7 +2221,7 @@ describe("PreviewManager", () => { 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), + }).pipe(Effect.provide(managerLayer())), ); effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 7f39a92bdb769..686248bf8892d 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -444,6 +444,9 @@ type FindResultListener = (event: DesktopPreviewFindResultEvent) => Effect.Effec const PAGE_FIRST_FORWARDED_COMMANDS: ReadonlySet = new Set([ "preview.find", + "preview.zoomIn", + "preview.zoomOut", + "preview.resetZoom", ]); const decodePageShortcutKeys = Schema.decodeUnknownOption( @@ -563,9 +566,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const random = yield* SecureRandom.SecureRandom; const parentScope = yield* Scope.Scope; const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; + const browserSession = yield* BrowserSession.BrowserSession; const passkeys = yield* PreviewPasskeys.PreviewPasskeys; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); + const runPromise = Effect.runPromiseWith(context); const resolvedArtifactDirectory = path.resolve(artifactDirectory); let forwardedShortcuts: ReadonlyArray = []; @@ -1551,7 +1556,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); 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. + // left unhandled, so a focused iframe (e.g. Google Docs' input) keeps its own find or zoom. if (PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; event.preventDefault(); if (!input.isAutoRepeat) { @@ -2472,7 +2477,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } if (afterAttach.serverTab) { - browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger }); + browserHost.attach(afterAttach.serverTab, { + webContents: wc, + debugger: control.debugger, + cookiesCleared: () => runPromise(browserSession.cookiesCleared(wc.session)), + }); // CDP cannot recover a failure that happened before it attached. const { navStatus } = afterAttach; if (navStatus.kind === "LoadFailed") { diff --git a/apps/desktop/src/preview/PickPreload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts index 8a080183b3830..61d3941ba41ab 100644 --- a/apps/desktop/src/preview/PickPreload.test.ts +++ b/apps/desktop/src/preview/PickPreload.test.ts @@ -62,3 +62,46 @@ it("keeps the tool while typing a tool key in the comment", () => { expect(document.documentElement.getAttribute("data-t3code-annotation-tool")).toBe("select"); }); + +it("keeps comment keystrokes away from the page's capture-phase shortcuts", () => { + const pageShortcut = vi.fn((event: KeyboardEvent) => event.preventDefault()); + window.addEventListener("keydown", pageShortcut, { capture: true }); + const shadowRoot = startAnnotation(); + const comment = shadowRoot.querySelector("textarea"); + if (!comment) throw new Error("comment textarea not found"); + + comment.focus(); + const space = new KeyboardEvent("keydown", { + key: " ", + code: "Space", + bubbles: true, + cancelable: true, + composed: true, + }); + comment.dispatchEvent(space); + window.removeEventListener("keydown", pageShortcut, { capture: true }); + + expect(pageShortcut).not.toHaveBeenCalled(); + expect(space.defaultPrevented).toBe(false); +}); + +it("keeps the annotation when Escape dismisses an IME candidate in the comment", () => { + const shadowRoot = startAnnotation(); + const comment = shadowRoot.querySelector("textarea"); + if (!comment) throw new Error("comment textarea not found"); + + comment.value = "draft"; + comment.focus(); + comment.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + isComposing: true, + bubbles: true, + cancelable: true, + composed: true, + }), + ); + + expect(shadowRoot.host.isConnected).toBe(true); + expect(comment.value).toBe("draft"); +}); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index a9f479d32b81a..cbaf873e49332 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -20,6 +20,13 @@ import type { import { createAnnotationIdSource } from "./AnnotationIds.ts"; import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; +import { + addStrokePoint, + openStrokePath, + startStroke, + strokeBounds, + type StrokeGeometry, +} from "./AnnotationStroke.ts"; import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; import { installRecordingCursor } from "./RecordingCursor.ts"; import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts"; @@ -33,6 +40,7 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + POINTER_DOWN_HOST_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_POINTER_CHANNEL, @@ -149,6 +157,7 @@ interface AnnotationSession { teardown: (notifyMain: boolean) => void; applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; setSendEnabled: (enabled: boolean) => void; + handleOverlayKey: (event: KeyboardEvent) => void; } let activeSession: AnnotationSession | null = null; @@ -185,6 +194,9 @@ const applyAnnotationTheme = ( const reportHumanPointerInput = (event: PointerEvent): void => { if (!event.isTrusted) return; + // The guest does not always take focus on a click, so the host's webview + // `focus` event can't be relied on to dismiss open menus and popovers. + ipcRenderer.sendToHost(POINTER_DOWN_HOST_CHANNEL); ipcRenderer.send(HUMAN_INPUT_CHANNEL, { kind: "pointer", x: event.clientX, @@ -225,6 +237,18 @@ const reportUnhandledShortcut = (event: KeyboardEvent): void => { window.addEventListener("keydown", reportUnhandledShortcut, true); +// Registered before page scripts so their capture listeners never see keys typed into the +// overlay; the closed shadow root retargets those to the host, hiding that they edit text. +const isolateOverlayKey = (event: KeyboardEvent): void => { + if (!activeSession || !(event.target instanceof Element) || !isAnnotationNode(event.target)) + return; + activeSession.handleOverlayKey(event); + event.stopImmediatePropagation(); +}; + +for (const type of ["keydown", "keypress", "keyup"] as const) + window.addEventListener(type, isolateOverlayKey, true); + const MOUSE_BUTTON_BACK = 3; const MOUSE_BUTTON_FORWARD = 4; @@ -533,34 +557,6 @@ function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement { return input; } -function pathFromPoints(points: ReadonlyArray): string { - if (points.length === 0) return ""; - if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; - let path = `M ${points[0]!.x} ${points[0]!.y}`; - for (let index = 1; index < points.length - 1; index += 1) { - const current = points[index]!; - const next = points[index + 1]!; - path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; - } - const last = points[points.length - 1]!; - path += ` L ${last.x} ${last.y}`; - return path; -} - -function strokeBounds( - points: ReadonlyArray, - width: number, -): PreviewAnnotationRect { - const xs = points.map((point) => point.x); - const ys = points.map((point) => point.y); - const padding = width + 3; - const left = Math.min(...xs) - padding; - const top = Math.min(...ys) - padding; - const right = Math.max(...xs) + padding; - const bottom = Math.max(...ys) + padding; - return { x: left, y: top, width: right - left, height: bottom - top }; -} - function startAnnotation( sendEnabled: boolean, voice: DesktopPreviewAnnotationVoiceConfig | undefined, @@ -897,7 +893,12 @@ function startAnnotation( } let tool: AnnotationTool = isPdf ? "marquee" : "select"; let dragStart: PreviewAnnotationPoint | null = null; - let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; + let activeStroke: { + target: PreviewAnnotationStrokeTarget; + geometry: StrokeGeometry; + group: SVGGElement; + path: SVGPathElement; + } | null = null; let pendingCapture = false; let editorExpanded = false; let editorWasShown = false; @@ -1497,15 +1498,18 @@ function startAnnotation( return; } if (tool === "draw" && activeStroke) { - activeStroke.target.points = [ - ...activeStroke.target.points, - { x: event.clientX, y: event.clientY }, - ]; - activeStroke.target.bounds = strokeBounds( - activeStroke.target.points, - activeStroke.target.width, - ); - activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); + const closedPath = addStrokePoint(activeStroke.geometry, { + x: event.clientX, + y: event.clientY, + }); + activeStroke.target.bounds = strokeBounds(activeStroke.geometry, activeStroke.target.width); + if (closedPath !== null) { + activeStroke.path.setAttribute("d", closedPath); + activeStroke.path = activeStroke.group.appendChild( + document.createElementNS("http://www.w3.org/2000/svg", "path"), + ); + } + activeStroke.path.setAttribute("d", openStrokePath(activeStroke.geometry)); } }; @@ -1524,23 +1528,27 @@ function startAnnotation( } dragStart = { x: event.clientX, y: event.clientY }; if (tool === "draw") { + const geometry = startStroke(dragStart); const stroke: PreviewAnnotationStrokeTarget = { id: nextId("stroke"), color: annotationTheme?.primary ?? "#2563eb", width: 4, - points: [dragStart], + points: geometry.points, bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, }; - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute(OVERLAY_ATTRIBUTE, ""); - path.setAttribute("data-stroke-id", stroke.id); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", stroke.color); - path.setAttribute("stroke-width", String(stroke.width)); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-linejoin", "round"); - svg.appendChild(path); - activeStroke = { target: stroke, path }; + const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); + group.setAttribute(OVERLAY_ATTRIBUTE, ""); + group.setAttribute("data-stroke-id", stroke.id); + group.setAttribute("fill", "none"); + group.setAttribute("stroke", stroke.color); + group.setAttribute("stroke-width", String(stroke.width)); + group.setAttribute("stroke-linecap", "round"); + group.setAttribute("stroke-linejoin", "round"); + const path = group.appendChild( + document.createElementNS("http://www.w3.org/2000/svg", "path"), + ); + svg.appendChild(group); + activeStroke = { target: stroke, geometry, group, path }; } }; @@ -1567,7 +1575,7 @@ function startAnnotation( } } else if (tool === "draw" && activeStroke) { if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); - else activeStroke.path.remove(); + else activeStroke.group.remove(); activeStroke = null; } dragStart = null; @@ -1673,7 +1681,7 @@ function startAnnotation( return; } } - if (event.key === "Escape") { + if (event.key === "Escape" && !event.isComposing) { event.preventDefault(); event.stopPropagation(); teardown(true); @@ -1762,15 +1770,17 @@ function startAnnotation( }); }; submit.addEventListener("click", () => submitAnnotation("attach")); - root.addEventListener("keydown", (event) => { + const handleOverlayKey = (event: KeyboardEvent): void => { + if (event.type === "keyup") onVoiceKeyUp(event); + if (event.type !== "keydown") return; + onKeyDown(event); + if (event.cancelBubble) return; const submission = - event.target === comment ? resolveAnnotationSubmission(event, sendEnabled) : null; - // Bubble phase lets editor inputs handle events before hiding them from the page. - event.stopImmediatePropagation(); + shadowRoot.activeElement === comment ? resolveAnnotationSubmission(event, sendEnabled) : null; if (!submission) return; event.preventDefault(); submitAnnotation(submission); - }); + }; window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); @@ -1806,6 +1816,7 @@ function startAnnotation( sendEnabled = enabled; updateSendHint(); }, + handleOverlayKey, }; } diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 868e1c139fbdd..04406f63c6b3d 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -4,6 +4,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; @@ -160,6 +161,49 @@ describe("DesktopSettings", () => { ), ); + it.effect("keeps saved preferences when a read fails before a bounds save", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + yield* writeSettingsPatch({ serverExposureMode: "network-accessible" }); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.desktopSettingsPath, + }); + let failRead = true; + const settings = yield* DesktopAppSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => { + if (failRead && path === environment.desktopSettingsPath) { + failRead = false; + return Effect.fail(cause); + } + return fileSystem.readFileString(path); + }), + }), + ), + ); + + yield* Effect.exit(settings.load); + yield* Effect.exit( + settings.setMainWindowBounds({ x: 10, y: 20, width: 900, height: 600 }, false), + ); + + const persisted = yield* decodeDesktopSettingsPatch( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + ); + assert.equal(persisted.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("loads lenient persisted desktop settings JSON", () => withSettings( Effect.gen(function* () { @@ -196,6 +240,28 @@ describe("DesktopSettings", () => { ), ); + it.effect("keeps Local environment off when another value in the file is unreadable", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ "localEnvironmentEnabled": false, "serverExposureMode": "tailnet" }\n`, + ); + + assert.isFalse((yield* settings.load).localEnvironmentEnabled); + yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 800, height: 600 }, false); + assert.include( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + `"localEnvironmentEnabled":false`, + ); + }), + ), + ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => withSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 4c2ae8d1d1342..e81da70660648 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -13,6 +13,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; @@ -88,20 +90,25 @@ const DesktopWindowBoundsDocument = Schema.Struct({ height: Schema.Number, }); +// A value this build cannot read drops only its own key, so the rest of the file +// still loads and is written back instead of every setting resetting to defaults. +const optionalReadableKey = (schema: S) => + Schema.optionalKey(schema.pipe(Schema.catchDecoding(() => Effect.succeedNone))); + const DesktopSettingsDocument = Schema.Struct({ - localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), - linuxPasswordStore: Schema.optionalKey(Schema.Unknown), - mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), - mainWindowMaximized: Schema.optionalKey(Schema.Boolean), - serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), - tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), - tailscaleServePort: Schema.optionalKey(Schema.Number), - updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), - updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), - wslBackendEnabled: Schema.optionalKey(Schema.Boolean), - wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), - wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), - wslOnly: Schema.optionalKey(Schema.Boolean), + localEnvironmentEnabled: optionalReadableKey(Schema.Boolean), + linuxPasswordStore: optionalReadableKey(Schema.Unknown), + mainWindowBounds: optionalReadableKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowMaximized: optionalReadableKey(Schema.Boolean), + serverExposureMode: optionalReadableKey(DesktopServerExposureModeSchema), + tailscaleServeEnabled: optionalReadableKey(Schema.Boolean), + tailscaleServePort: optionalReadableKey(Schema.Number), + updateChannel: optionalReadableKey(DesktopUpdateChannelSchema), + updateChannelConfiguredByUser: optionalReadableKey(Schema.Boolean), + wslBackendEnabled: optionalReadableKey(Schema.Boolean), + wslMode: optionalReadableKey(Schema.Literals(["local", "wsl"])), + wslDistro: optionalReadableKey(Schema.NullOr(Schema.String)), + wslOnly: optionalReadableKey(Schema.Boolean), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; @@ -379,15 +386,20 @@ function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { return setWslOnly(setWslBackendEnabled(settings, false), false); } +// Fails only when the file may exist but could not be read; a missing file means defaults. function readSettings( fileSystem: FileSystem.FileSystem, settingsPath: string, appVersion: string, -): Effect.Effect { +): Effect.Effect { const defaultSettings = resolveDefaultDesktopSettings(appVersion); return fileSystem.readFileString(settingsPath).pipe( - Effect.option, + Effect.asSome, + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.succeed(Option.none()), + ), Effect.flatMap( Option.match({ onNone: () => Effect.succeed(defaultSettings), @@ -476,6 +488,8 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const random = yield* SecureRandom.SecureRandom; const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); + // Writing over a settings file we could not read would erase the preferences it holds. + const persistenceEnabled = yield* Ref.make(true); const updateInMemory = (update: (settings: DesktopSettings) => DesktopSettings) => SynchronizedRef.modify(settingsRef, (settings) => { @@ -492,17 +506,23 @@ export const make = Effect.gen(function* () { return Effect.succeed([settingsChange(settings, false), settings] as const); } - return random.uuidV4().pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.flatMap((suffix) => - writeSettings({ - fileSystem, - path, - settingsPath: environment.desktopSettingsPath, - settings: nextSettings, - defaultSettings: environment.defaultDesktopSettings, - suffix, - }), + return Ref.get(persistenceEnabled).pipe( + Effect.flatMap((enabled) => + enabled + ? random.uuidV4().pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.flatMap((suffix) => + writeSettings({ + fileSystem, + path, + settingsPath: environment.desktopSettingsPath, + settings: nextSettings, + defaultSettings: environment.defaultDesktopSettings, + suffix, + }), + ), + ) + : Effect.void, ), Effect.as([settingsChange(nextSettings, true), nextSettings] as const), ); @@ -515,6 +535,17 @@ export const make = Effect.gen(function* () { fileSystem, environment.desktopSettingsPath, environment.appVersion, + ).pipe( + Effect.catch((cause) => + Effect.logWarning( + "Could not read desktop settings; using defaults without saving changes.", + cause, + ).pipe( + Effect.annotateLogs({ settingsPath: environment.desktopSettingsPath }), + Effect.andThen(Ref.set(persistenceEnabled, false)), + Effect.as(resolveDefaultDesktopSettings(environment.appVersion)), + ), + ), ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1ab9b3159e1f9..90c8740f83725 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -110,7 +110,7 @@ const withClientSettings = ( prefix: "t3-desktop-client-settings-test-", }); return yield* effect.pipe(Effect.provide(layer(baseDir))); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); describe("DesktopClientSettings", () => { it.effect("saves through a symlinked client settings file without replacing the link", () => diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 0001548f4534f..7be39dbe1562d 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -150,7 +150,7 @@ const withSavedEnvironments = ( prefix: "t3-desktop-saved-environments-test-", }); return yield* effect.pipe(Effect.provide(layer(baseDir, options))); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + }).pipe(Effect.provide(NodeServices.layer)); describe("DesktopSavedEnvironments", () => { it.effect("loads lenient saved environment registry documents", () => diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 0c7869194323c..c00e2ea7c65a2 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -637,6 +637,52 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("recovers the login-shell PATH when the first probe outlasts the timeout", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }; + const output = envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }); + let shellProbes = 0; + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => "", + processSpawner: ChildProcessSpawner.make((command) => { + if (command._tag !== "StandardCommand" || command.command !== "/bin/zsh") { + return Effect.succeed(makeProcess("")); + } + shellProbes += 1; + if (shellProbes > 1) return Effect.succeed(makeProcess(output)); + // A cold start: the first login shell answers, but only after 7s. + const slow = Effect.sleep("7 seconds"); + const stdout = Stream.fromEffect(slow.pipe(Effect.as(textEncoder.encode(output)))); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(125), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode: slow.pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ); + }), + use: (service) => + Effect.gen(function* () { + yield* service.installIntoProcess; + yield* TestClock.adjust("10 minutes"); + return true; + }), + }).pipe(Effect.provide(Logger.layer([]))); + + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"); + }), + ); + it.effect("loads PowerShell profile environment on Windows", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 9d9e390c95496..f0983ca622490 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -117,6 +117,8 @@ const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; const FALLBACK_LC_CTYPE = "en_US.UTF-8"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); +// A profile can be slow only while the machine boots; one longer wait covers it. +const SLOW_LOGIN_SHELL_TIMEOUT = Duration.seconds(20); const LAUNCHCTL_TIMEOUT = Duration.seconds(2); const PROCESS_TERMINATE_GRACE = Duration.seconds(1); @@ -261,7 +263,11 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( readonly env: NodeJS.ProcessEnv; readonly timeout: Duration.Duration; readonly shell?: boolean; -}): Effect.fn.Return { +}): Effect.fn.Return< + string, + DesktopShellEnvironmentCommandTimeoutError, + ChildProcessSpawner.ChildProcessSpawner +> { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fiber = yield* Effect.scoped( Effect.gen(function* () { @@ -317,21 +323,34 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( return output.value; } - const error = new DesktopShellEnvironmentCommandTimeoutError({ + return yield* new DesktopShellEnvironmentCommandTimeoutError({ probe: input.probe, executable: executableName(input.command), argumentCount: input.args.length, timeoutMs: Duration.toMillis(input.timeout), }); - yield* logShellEnvironmentCommandError(error); - return ""; }); +const orEmptyOnTimeout = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.catchTags({ + DesktopShellEnvironmentCommandTimeoutError: (error) => + logShellEnvironmentCommandError(error).pipe(Effect.as("")), + }), + ); + const readLoginShellEnvironment = ( shell: string, names: ReadonlyArray, env: NodeJS.ProcessEnv, -): Effect.Effect => + timeout: Duration.Duration, +): Effect.Effect< + EnvironmentPatch, + DesktopShellEnvironmentCommandTimeoutError, + ChildProcessSpawner.ChildProcessSpawner +> => names.length === 0 ? Effect.succeed({}) : runCommandOutput({ @@ -339,7 +358,7 @@ const readLoginShellEnvironment = ( command: shell, args: ["-ilc", buildEnvironmentCaptureCommand(shell, names)], env, - timeout: LOGIN_SHELL_TIMEOUT, + timeout, }).pipe(Effect.map((output) => extractEnvironment(output, names))); const readLaunchctlPath = (env: NodeJS.ProcessEnv) => @@ -349,7 +368,7 @@ const readLaunchctlPath = (env: NodeJS.ProcessEnv) => command: "/bin/launchctl", args: ["getenv", "PATH"], timeout: LAUNCHCTL_TIMEOUT, - }).pipe(Effect.map(trimNonEmpty)); + }).pipe(orEmptyOnTimeout, Effect.map(trimNonEmpty)); const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")( function* ( @@ -374,7 +393,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn args, env, timeout: LOGIN_SHELL_TIMEOUT, - }); + }).pipe(orEmptyOnTimeout); const environment = extractEnvironment(output, names); if (Object.keys(environment).length > 0) { return environment; @@ -429,10 +448,27 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix const userShells = [config.env.SHELL?.trim(), readUserLoginShell()]; let answeredBy: string | undefined; + let waitedForSlowShell = false; for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) { + const read = (timeout: Duration.Duration) => + readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env, timeout); Object.assign( shellEnvironment, - yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env), + yield* read(LOGIN_SHELL_TIMEOUT).pipe( + Effect.catchTags({ + DesktopShellEnvironmentCommandTimeoutError: (error) => { + const retry = !waitedForSlowShell; + waitedForSlowShell = true; + return logShellEnvironmentCommandError(error).pipe( + Effect.andThen(retry ? read(SLOW_LOGIN_SHELL_TIMEOUT) : Effect.succeed({})), + ); + }, + }), + Effect.catchTags({ + DesktopShellEnvironmentCommandTimeoutError: (error) => + logShellEnvironmentCommandError(error).pipe(Effect.as({})), + }), + ), ); if (shellEnvironment.PATH) { answeredBy = shell; diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts index 35c007e35dc0e..1a85c9a4cd891 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts @@ -30,6 +30,7 @@ class FakeBus extends NodeEvents.EventEmitter { requestPath = ""; boundId = ""; foreignHandle = false; + owner = ":1.2"; send(message: Message) { this.sends.push(message); @@ -39,7 +40,7 @@ class FakeBus extends NodeEvents.EventEmitter { member: string, path: string, body: unknown[], - sender = ":1.2", + sender = this.owner, signature = "", ) { this.emit( @@ -71,7 +72,7 @@ class FakeBus extends NodeEvents.EventEmitter { ]), }); } - activate(id = this.boundId, session = this.session, sender = ":1.2") { + activate(id = this.boundId, session = this.session, sender = this.owner) { this.signal(portal, "Activated", root, [session, id, 1, {}], sender, "osta{sv}"); } async call(message: Message) { @@ -87,7 +88,7 @@ class FakeBus extends NodeEvents.EventEmitter { if (this.registryError) throw this.registryError; return reply(); } - if (message.member === "GetNameOwner") return reply([":1.2"]); + if (message.member === "GetNameOwner") return reply([this.owner]); if (message.member === "Get") return reply([new Variant("u", this.version)]); if (message.member === "AddMatch" || message.member === "ConfigureShortcuts") return reply(); const options = message.body.at(-1) as Record>; @@ -338,7 +339,30 @@ it("handles a disappearing portal without keeping a false registered state", asy ); expect(client.state.shortcutRegistered).toBe(false); expect(client.state.shortcutMessage).toContain("restarted"); - expect(bus.disconnect).toHaveBeenCalledOnce(); + expect(client.hasSession).toBe(false); + expect(bus.disconnect).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(client.state.shortcutRegistered).toBe(true)); + expect(bus.calls.filter((call) => call.member === "CreateSession")).toHaveLength(2); +}); + +it("binds a new session when the portal restarts", async () => { + const { bus, client, capture } = start(new FakeBus(), chord, true); + await client.ready; + const ownerChanged = (from: string, to: string) => + bus.signal( + "org.freedesktop.DBus", + "NameOwnerChanged", + "/org/freedesktop/DBus", + ["org.freedesktop.portal.Desktop", from, to], + "org.freedesktop.DBus", + ); + ownerChanged(":1.2", ""); + bus.owner = ":1.3"; + ownerChanged("", ":1.3"); + await vi.waitFor(() => expect(client.state.shortcutActionRegistered).toBe(true)); + expect(bus.calls.filter((call) => call.member === "CreateSession")).toHaveLength(2); + bus.activate(); + expect(capture).toHaveBeenCalledOnce(); }); it("accepts old portals without Registry but does not ignore permission denial", async () => { diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts index ec42af46a3881..8d44ec86d4fb7 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts @@ -67,6 +67,7 @@ export class PortalCaptureShortcut { }; readonly ready: Promise; private closed = false; + private connection = 0; private owner = ""; private namespace = ""; private session = ""; @@ -74,8 +75,10 @@ export class PortalCaptureShortcut { private version = 0; private pending: { path: string; resolve: (body: unknown) => void } | undefined; private responses = new Map(); - private readonly stopped: Promise; + private stopped!: Promise; private rejectStopped!: (reason: Error) => void; + private readonly appId: string; + private readonly trigger: string; private readonly onCapture: () => void; private readonly onStateChanged: () => void; private readonly bus: MessageBus; @@ -89,6 +92,8 @@ export class PortalCaptureShortcut { bus: MessageBus = sessionBus(), managedByHyprland = false, ) { + this.appId = appId; + this.trigger = portalShortcutTrigger(shortcut); this.onCapture = onCapture; this.onStateChanged = onStateChanged; this.bus = bus; @@ -99,13 +104,41 @@ export class PortalCaptureShortcut { shortcutPending: true, shortcutMessage: "Connecting to Hyprland shortcuts…", }; + this.resetStopped(); + bus.on("error", this.failed); + bus.on("message", this.message); + this.ready = this.connect(true); + } + + private resetStopped() { this.stopped = new Promise((_, reject) => { this.rejectStopped = reject; }); void this.stopped.catch(() => undefined); - bus.on("error", this.failed); - bus.on("message", this.message); - this.ready = this.initialize(appId, shortcut).catch(this.failed); + } + + private connect(first = false) { + const connection = ++this.connection; + return this.initialize(first).catch((error: unknown) => { + if (connection === this.connection) this.failed(error); + }); + } + + private portalGone() { + this.rejectStopped(new Error("The desktop shortcut service restarted.")); + this.resetStopped(); + this.owner = ""; + this.session = ""; + this.pending = undefined; + this.responses.clear(); + this.update({ + shortcutRegistered: false, + shortcutActionRegistered: false, + shortcutPending: true, + shortcutMessage: "The desktop shortcut service restarted. Reconnecting…", + }); + // Register is sent to the well-known name, so it D-Bus-activates a portal that is not running. + void this.connect(); } close = () => { @@ -209,10 +242,9 @@ export class PortalCaptureShortcut { message.sender === "org.freedesktop.DBus" && message.interface === DBUS && message.member === "NameOwnerChanged" && - message.body[0] === PORTAL && - message.body[1] === this.owner + message.body[0] === PORTAL ) { - this.failed(new Error("The desktop shortcut service restarted. Retry the shortcut request.")); + if (this.owner && message.body[1] === this.owner) this.portalGone(); return; } if (!this.owner || message.sender !== this.owner) return; @@ -270,7 +302,8 @@ export class PortalCaptureShortcut { const response = new Promise((done) => { resolve = done; }); - this.pending = { path: expectedPath, resolve }; + const pending = { path: expectedPath, resolve }; + this.pending = pending; this.responses.clear(); let completed = false; try { @@ -284,7 +317,7 @@ export class PortalCaptureShortcut { }); const handle = string(reply.body[0]); if (!handle.startsWith(this.namespace)) throw new Error("Invalid shortcut request handle."); - this.pending.path = handle; + pending.path = handle; if (this.responses.has(handle)) resolve(this.responses.get(handle)); const [status, results] = decodeResponse( await this.wait(response, member === "BindShortcuts" ? 120_000 : 5_000), @@ -306,9 +339,11 @@ export class PortalCaptureShortcut { } return results; } finally { - if (!completed && !this.closed && this.pending) this.closeObject(this.pending.path, REQUEST); - this.pending = undefined; - this.responses.clear(); + if (this.pending === pending) { + if (!completed && !this.closed) this.closeObject(pending.path, REQUEST); + this.pending = undefined; + this.responses.clear(); + } } } @@ -338,8 +373,8 @@ export class PortalCaptureShortcut { }); } - private async initialize(appId: string, shortcut: SnapShotKeyChord) { - const trigger = portalShortcutTrigger(shortcut); + private async initialize(first: boolean) { + const trigger = this.trigger; if (!process.env.FLATPAK_ID && !process.env.SNAP) { await this.call({ destination: PORTAL, @@ -347,7 +382,7 @@ export class PortalCaptureShortcut { interface: "org.freedesktop.host.portal.Registry", member: "Register", signature: "sa{sv}", - body: [appId, {}], + body: [this.appId, {}], }).catch((error: unknown) => { if ( !( @@ -381,7 +416,11 @@ export class PortalCaptureShortcut { this.version = decodeVersion(version.body[0]).value; for (const rule of [ `type='signal',sender='${this.owner}',path_namespace='${PATH}'`, - `type='signal',sender='org.freedesktop.DBus',interface='${DBUS}',member='NameOwnerChanged',arg0='${PORTAL}'`, + ...(first + ? [ + `type='signal',sender='org.freedesktop.DBus',interface='${DBUS}',member='NameOwnerChanged',arg0='${PORTAL}'`, + ] + : []), ]) await this.call({ destination: DBUS, diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 7b480b36fbd12..d47984e2bcb59 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -64,6 +64,7 @@ export class DesktopSshEnvironment extends Context.Service< readonly disconnectEnvironment: ( target: DesktopSshEnvironmentTarget, ) => Effect.Effect; + readonly closeAllTunnels: Effect.Effect; } >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} @@ -156,6 +157,7 @@ export const make = Effect.gen(function* () { Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), Effect.provide(runtimeContext), ), + closeAllTunnels: manager.closeAllTunnels, }); }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index 1e5b6dafc6cae..15977b34f398b 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -144,7 +144,7 @@ describe("DesktopSshPasswordPrompts", () => { yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); assert.equal(yield* Fiber.join(fiber), "secret"); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }).pipe(Effect.provide(layer(testWindow.window))); }); it.effect.each(["answered", "cancelled", "timed out", "interrupted"] as const)( @@ -189,7 +189,7 @@ describe("DesktopSshPasswordPrompts", () => { .map((message) => message.args), [[request.requestId]], ); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }).pipe(Effect.provide(layer(testWindow.window))); }, ); @@ -219,6 +219,6 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(password, "secret"); assert.equal(testWindow.isFocused(), false); assert.equal(testWindow.closedListenerCount(), 0); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }).pipe(Effect.provide(layer(testWindow.window))); }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 68002f826d421..7ee2ff5dd51c1 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -247,6 +247,26 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("checks shortly after startup and then every four hours", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* TestClock.adjust("15 seconds"); + assert.equal(harness.checkCount(), 1); + + yield* TestClock.adjust("3 hours"); + assert.equal(harness.checkCount(), 1); + + yield* TestClock.adjust("1 hour"); + assert.equal(harness.checkCount(), 2); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect( "rejects install while a refresh check is in progress and releases the reservation", () => @@ -524,14 +544,17 @@ describe("DesktopUpdates", () => { it.effect("marks the backend stop for an install as an update restart", () => { let markersAtStop: ReadonlyArray = []; + let inProgressAtStop: string | undefined; const harness = makeHarness({ stopBackend: Effect.sync(() => { markersAtStop = [...harness.updateRestartMarkers]; + inProgressAtStop = harness.updateInProgressMarker(); }), }); return Effect.scoped( Effect.gen(function* () { + yield* TestClock.setTime(1_000); const environment = yield* DesktopEnvironment.DesktopEnvironment; const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; @@ -542,6 +565,12 @@ describe("DesktopUpdates", () => { assert.deepEqual(markersAtStop, [ environment.path.join(environment.baseDir, "runtime", DESKTOP_UPDATE_RESTART_MARKER_FILE), ]); + // SSH launchers wait on this until the replacement backend publishes its runtime. + assert.deepEqual(JSON.parse(inProgressAtStop ?? "null"), { + startedAt: 1_000, + expiresAt: 61_000, + }); + assert.isDefined(harness.updateInProgressMarker()); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); @@ -624,9 +653,11 @@ describe("DesktopUpdates", () => { const installFiber = yield* updates.install.pipe(Effect.forkScoped); yield* Deferred.await(stopping); assert.equal(harness.updateRestartMarkers.size, 1); + assert.isDefined(harness.updateInProgressMarker()); yield* Fiber.interrupt(installFiber); assert.equal(harness.updateRestartMarkers.size, 0); + assert.isUndefined(harness.updateInProgressMarker()); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }), @@ -655,8 +686,13 @@ describe("DesktopUpdates", () => { const result = yield* updates.install; assert.isTrue(result.accepted); assert.isFalse(yield* Ref.get(desktopState.quitting)); - assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); + assert.deepEqual(harness.installSteps, [ + "closeSshTunnels", + "quitAndInstall", + "startBackend", + ]); assert.equal(harness.updateRestartMarkers.size, 0); + assert.isUndefined(harness.updateInProgressMarker()); assert.equal(harness.installRecords.size, 0); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); @@ -685,7 +721,7 @@ describe("DesktopUpdates", () => { const result = yield* updates.install; assert.equal(result.state.errorContext, "install"); - assert.deepEqual(harness.installSteps, ["quitAndInstall"]); + assert.deepEqual(harness.installSteps, ["closeSshTunnels", "quitAndInstall"]); assert.isFalse((yield* (yield* pool.primary).snapshot).desiredRunning); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); @@ -779,7 +815,11 @@ describe("DesktopUpdates", () => { assert.equal(harness.quitAndInstalls(), 1); harness.emit("error", new Error("duplicate native installer error")); yield* flushCallbacks; - assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); + assert.deepEqual(harness.installSteps, [ + "closeSshTunnels", + "quitAndInstall", + "startBackend", + ]); yield* Deferred.succeed(releaseRecovery, undefined); const failedResult = yield* Fiber.join(failedInstall); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 0f52ec249c2c4..db31e0bc112ba 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,5 +1,7 @@ import { DESKTOP_UPDATE_RESTART_MARKER_FILE, + DESKTOP_UPDATE_IN_PROGRESS_MARKER_FILE, + DESKTOP_UPDATE_IN_PROGRESS_TTL_MS, DesktopUpdateChannelSchema, type DesktopRuntimeInfo, type DesktopUpdateActionResult, @@ -35,6 +37,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopSshEnvironment from "../ssh/DesktopSshEnvironment.ts"; import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import { normalizeDesktopUpdateReleaseNotes, @@ -59,7 +62,7 @@ import { const CHANNEL_CHANGE_RETRY_DELAY = Duration.seconds(1); const CHANNEL_CHANGE_RETRIES = 30; const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; -const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; +const AUTO_UPDATE_POLL_INTERVAL = "4 hours"; const PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90); type UpdateAction = "check" | "download" | "install" | "install-recovery" | "channel"; @@ -369,6 +372,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const pool = yield* DesktopBackendPool.DesktopBackendPool; + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; const desktopState = yield* DesktopState.DesktopState; const electronUpdater = yield* ElectronUpdater.ElectronUpdater; const electronWindow = yield* ElectronWindow.ElectronWindow; @@ -667,6 +671,34 @@ export const make = Effect.gen(function* () { ), ); + const updateInProgressMarkerPath = environment.path.join( + updateRestartMarkerDir, + DESKTOP_UPDATE_IN_PROGRESS_MARKER_FILE, + ); + const encodeUpdateMarker = Schema.encodeEffect( + Schema.fromJsonString(Schema.Struct({ startedAt: Schema.Number, expiresAt: Schema.Number })), + ); + const updateInProgressTempPath = `${updateInProgressMarkerPath}.tmp`; + const writeUpdateInProgressMarker = Effect.gen(function* () { + const startedAt = DateTime.toEpochMillis(yield* DateTime.now); + const contents = yield* encodeUpdateMarker({ + startedAt, + expiresAt: startedAt + DESKTOP_UPDATE_IN_PROGRESS_TTL_MS, + }); + yield* fileSystem.writeFileString(updateInProgressTempPath, contents); + yield* fileSystem.rename(updateInProgressTempPath, updateInProgressMarkerPath); + }).pipe( + Effect.ensuring( + fileSystem.remove(updateInProgressTempPath, { force: true }).pipe(Effect.ignore), + ), + Effect.catch((error) => + logUpdaterWarning("Could not write the update-in-progress marker.", { errorTag: error._tag }), + ), + ); + const removeUpdateInProgressMarker = fileSystem + .remove(updateInProgressMarkerPath, { force: true }) + .pipe(Effect.ignore); + // Failed or interrupted installs leave no replacement backend; later quits must release the tunnel. const removeUpdateRestartMarker = fileSystem .remove(updateRestartMarkerPath, { force: true }) @@ -722,6 +754,7 @@ export const make = Effect.gen(function* () { Ref.set(desktopState.quitting, false), Ref.set(installRunningBackendsRef, []), removeUpdateRestartMarker, + removeUpdateInProgressMarker, removeInstallRecord, ], { discard: true }, @@ -739,6 +772,7 @@ export const make = Effect.gen(function* () { yield* Ref.set(desktopState.quitting, false); yield* removeUpdateRestartMarker; + yield* removeUpdateInProgressMarker; yield* removeInstallRecord; yield* Effect.gen(function* () { const runningInstances = yield* Ref.getAndSet(installRunningBackendsRef, []); @@ -814,19 +848,26 @@ export const make = Effect.gen(function* () { return yield* Effect.gen(function* () { yield* writeUpdateRestartMarker; + yield* writeUpdateInProgressMarker; const { downloadedVersion } = yield* Ref.get(updateStateRef); if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion); - // Stop pooled backends before quitAndInstall can outrun their scope finalizers. + // Stop pooled backends and SSH tunnels before quitAndInstall can outrun their scope finalizers. const instances = yield* pool.list; // stop clears desiredRunning, so retain the recovery set beforehand. const runningInstances = yield* Effect.filter(instances, (instance) => instance.snapshot.pipe(Effect.map((snapshot) => snapshot.desiredRunning)), ); yield* Ref.set(installRunningBackendsRef, runningInstances); - yield* Effect.forEach( - instances, - (instance) => - instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }), + yield* Effect.all( + [ + Effect.forEach( + instances, + (instance) => + instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }), + { concurrency: "unbounded" }, + ), + sshEnvironment.closeAllTunnels, + ], { concurrency: "unbounded" }, ); yield* electronUpdater.quitAndInstall({ diff --git a/apps/desktop/src/updates/githubUpdateFeed.test.ts b/apps/desktop/src/updates/githubUpdateFeed.test.ts new file mode 100644 index 0000000000000..d9e928fce186b --- /dev/null +++ b/apps/desktop/src/updates/githubUpdateFeed.test.ts @@ -0,0 +1,107 @@ +import * as NodeModule from "node:module"; + +import { GitHubProvider } from "electron-updater/out/providers/GitHubProvider.js"; +import { describe, expect, it } from "vite-plus/test"; + +type ProviderArgs = ConstructorParameters; + +// electron-updater's own dependency, which apps/desktop does not declare. +const semver = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("electron-updater"), +)("semver") as { parse: (version: string) => ProviderArgs[1]["currentVersion"] }; + +const REPO = "/pingdotgg/t3code"; +const nightly = (build: number) => `v0.1.10-nightly.20261112.${build}`; + +const atomFeed = (tags: ReadonlyArray) => + `${tags + .map( + (tag) => + `${tag}${tag} feed notes`, + ) + .join("")}`; + +/** + * The release a nightly install on 4580 is offered when GitHub serves this feed, this `update-feed` + * pointer, and this newest nightly from the REST API scan. + */ +const offeredRelease = async ( + feedTags: ReadonlyArray, + pointerTag: string | undefined, + listedTag?: string | Error, +) => { + let apiScans = 0; + const request = async ({ path = "" }: { readonly path?: string | null }) => { + if (path === `${REPO}/releases.atom`) return atomFeed(feedTags); + if (path === `${REPO}/releases/download/update-feed/nightly.json`) { + if (pointerTag === undefined) throw new Error("404"); + return JSON.stringify({ tag_name: pointerTag, name: pointerTag, body: "pointer notes" }); + } + const manifest = /\/releases\/download\/v([^/]+)\/nightly\.yml$/.exec(path ?? ""); + if (manifest) return `version: ${manifest[1]}\npath: T3-Code.exe\nsha512: abc\n`; + apiScans += 1; + if (listedTag instanceof Error) throw listedTag; + return JSON.stringify(listedTag ? [{ tag_name: listedTag, body: "API notes" }] : []); + }; + const provider = new GitHubProvider( + { provider: "github", owner: "pingdotgg", repo: "t3code" }, + // Only the four fields the provider reads; a real AppUpdater needs Electron. + { + allowPrerelease: true, + channel: "nightly", + currentVersion: semver.parse("0.1.10-nightly.20261112.4580"), + fullChangelog: true, + } as ProviderArgs[1], + { + isUseMultipleRangeRequest: false, + platform: "win32", + executor: { request }, + } as ProviderArgs[2], + ); + const { tag, releaseNotes } = await provider.getLatestVersion(); + return { tag, releaseNotes, apiScans }; +}; + +// What filled releases.atom on 2026-11-12: ten tags, none of them a nightly. +const crowdedFeed = Array.from({ length: 10 }, (_, i) => `v0.1.10-preview.20261112.${4550 + i}`); + +describe("GitHubProvider nightly lookup", () => { + it.each([ + ["other tags fill the feed", crowdedFeed], + ["the feed's first nightly is an older one", [nightly(4603)]], + ])("finds the newest nightly at the fixed pointer when %s", async (_case, feedTags) => { + expect(await offeredRelease(feedTags, nightly(4606))).toEqual({ + tag: nightly(4606), + releaseNotes: "pointer notes", + apiScans: 0, + }); + }); + + it("still scans the API when the feed has no nightly and the pointer offers nothing new", async () => { + expect(await offeredRelease(crowdedFeed, nightly(4580), nightly(4606))).toEqual({ + tag: nightly(4606), + releaseNotes: "API notes", + apiScans: 1, + }); + }); + + it("keeps the pointer's answer when the API scan fails", async () => { + const rateLimited = new Error("403"); + expect(await offeredRelease(crowdedFeed, nightly(4580), rateLimited)).toMatchObject({ + tag: nightly(4580), + apiScans: 1, + }); + }); + + it.each([ + ["the feed is newer than the pointer", nightly(4603)], + ["there is no pointer", undefined], + ["the pointer names another channel", "v0.1.10-preview.20261112.4700"], + ])("keeps the feed's nightly when %s", async (_case, pointerTag) => { + expect(await offeredRelease([nightly(4606)], pointerTag)).toEqual({ + tag: nightly(4606), + releaseNotes: [{ version: nightly(4606).slice(1), note: `${nightly(4606)} feed notes` }], + apiScans: 0, + }); + }); +}); diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 6f05b1e9e94ab..a8fcc70ea355c 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -241,14 +241,20 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); - it("does not throw on out-of-range numeric entities and keeps the literal", () => { + it("keeps unsupported entities literal, including out-of-range and Object.prototype names", () => { const result = normalizeDesktopUpdateReleaseNotes( - "- Broken entity �", + "- Broken entity �\n- Mentions &constructor; and &toString;", "1.0.0", "latest", ); expect(result).toEqual({ - releaseNotes: [{ version: "1.0.0", items: ["Broken entity �"], totalItems: 1 }], + releaseNotes: [ + { + version: "1.0.0", + items: ["Mentions &constructor; and &toString;", "Broken entity �"], + totalItems: 2, + }, + ], omittedReleaseCount: 0, }); }); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 715c1767e6a04..30bfb9ace64b8 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -20,14 +20,14 @@ const MAX_RELEASE_NOTE_GROUPS = 6; const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; const MAX_RELEASE_NOTE_ITEM_LENGTH = 220; -const HTML_ENTITY_REPLACEMENTS: Readonly> = { - amp: "&", - apos: "'", - gt: ">", - lt: "<", - nbsp: " ", - quot: '"', -}; +const HTML_ENTITY_REPLACEMENTS: ReadonlyMap = new Map([ + ["amp", "&"], + ["apos", "'"], + ["gt", ">"], + ["lt", "<"], + ["nbsp", " "], + ["quot", '"'], +]); function decodeCodePoint(codePoint: number, entity: string): string { if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { @@ -37,7 +37,7 @@ function decodeCodePoint(codePoint: number, entity: string): string { } function decodeHtmlEntity(entity: string): string { - const named = HTML_ENTITY_REPLACEMENTS[entity]; + const named = HTML_ENTITY_REPLACEMENTS.get(entity); if (named) return named; if (entity.startsWith("#x")) { return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity); diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 54698130ab582..b118d15ced674 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopSshEnvironment from "../ssh/DesktopSshEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; @@ -136,6 +137,12 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { syncAllAppearance: () => Effect.void, } satisfies ElectronWindow.ElectronWindow["Service"]); + const layerSshEnvironment = Layer.mock(DesktopSshEnvironment.DesktopSshEnvironment, { + closeAllTunnels: Effect.sync(() => { + installSteps.push("closeSshTunnels"); + }), + }); + let backendDesiredRunning = options.backendDesiredRunning ?? true; const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { id: DesktopBackendPool.PRIMARY_INSTANCE_ID, @@ -237,6 +244,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { : DesktopAppSettings.layer; const updateRestartMarkers = new Set(); + const updateMarkerContents = new Map(); const installRecords = new Map(); let previousInstallRecord = options.installRecord; const isInstallRecord = (path: string) => @@ -262,11 +270,21 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { writeFileString: (path, data) => Effect.sync(() => { if (isInstallRecord(path)) installRecords.set(path, data); - else updateRestartMarkers.add(path); + else { + if (path.endsWith("/desktop-update-restart")) updateRestartMarkers.add(path); + updateMarkerContents.set(path, data); + } + }), + rename: (from, to) => + Effect.sync(() => { + const contents = updateMarkerContents.get(from); + if (contents !== undefined) updateMarkerContents.set(to, contents); + updateMarkerContents.delete(from); }), remove: (path) => Effect.sync(() => { updateRestartMarkers.delete(path); + updateMarkerContents.delete(path); installRecords.delete(path); if (isInstallRecord(path)) previousInstallRecord = undefined; }), @@ -278,6 +296,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { Layer.provideMerge(layerUpdater), Layer.provideMerge(layerWindow), Layer.provideMerge(layerBackend), + Layer.provideMerge(layerSshEnvironment), Layer.provideMerge(DesktopState.layer), Layer.provideMerge(layerSettings), Layer.provideMerge( @@ -298,6 +317,8 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { quitAndInstalls: () => quitAndInstallCount, installSteps, updateRestartMarkers, + updateInProgressMarker: () => + [...updateMarkerContents].find(([path]) => path.endsWith("/desktop-update-in-progress"))?.[1], installRecords, previousInstallRecord: () => previousInstallRecord, downloadCount: () => downloadCount, diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 8468a9538630c..d9ae6e76c29c2 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -154,7 +154,7 @@ describe("DesktopWslBackend", () => { assert.equal(startCount, 1); assert.deepEqual(yield* backend.lastPreflightError, refusal); - yield* clearFailure(new URL("http://127.0.0.1:41773")); + yield* clearFailure(new URL("http://127.0.0.1:41773"), 123); assert.deepEqual(yield* backend.lastPreflightError, Option.none()); yield* recordOwnershipFailure(); const settings = yield* DesktopAppSettings.DesktopAppSettings; diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts index 7eaf362a326aa..34dcbea08a0ee 100644 --- a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts +++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts @@ -6,7 +6,6 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; -import * as Scope from "effect/Scope"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -42,20 +41,14 @@ const layerEnvironment = (input: { ), ); -const withTempDir = ( - run: (tempDir: string) => Effect.Effect, -): Effect.Effect< - A, - E | PlatformError.PlatformError, - FileSystem.FileSystem | Exclude -> => +const withTempDir = (run: (tempDir: string) => Effect.Effect) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-wsl-server-tree-test-", }); return yield* run(tempDir); - }).pipe(Effect.scoped); + }); const ensureWith = (input: { readonly baseDir: string; diff --git a/apps/desktop/src/wsl/wslPathParsing.test.ts b/apps/desktop/src/wsl/wslPathParsing.test.ts index 0a814229665dd..ed491f58381ae 100644 --- a/apps/desktop/src/wsl/wslPathParsing.test.ts +++ b/apps/desktop/src/wsl/wslPathParsing.test.ts @@ -77,8 +77,15 @@ describe("wslUncPathToLinuxPath", () => { expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\Debian\\")).toBe("/"); }); + it("maps UNC paths of distros whose names start or end with a dot or hyphen", () => { + for (const name of ["Ubuntu-", "Ubuntu.", ".Ubuntu", "-Ubuntu"]) { + expect(wslUncPathToLinuxPath(`\\\\wsl.localhost\\${name}\\home\\josh`)).toBe("/home/josh"); + } + }); + it("rejects invalid distro names and non-WSL paths", () => { expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\bad!name\\home")).toBeNull(); + expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\..\\home")).toBeNull(); expect(wslUncPathToLinuxPath("C:\\Users\\Josh\\repo")).toBeNull(); }); }); @@ -107,9 +114,13 @@ describe("resolveWslPickFolderDefaultPath", () => { }); describe("isValidDistroName", () => { - it("rejects names with trailing whitespace, hyphen, or dot", () => { - for (const name of ["Ubuntu ", "Ubuntu-", "Ubuntu."]) { - expect(isValidDistroName(name)).toBe(false); + it("rejects names with trailing whitespace", () => { + expect(isValidDistroName("Ubuntu ")).toBe(false); + }); + + it("accepts names WSL registers with a leading or trailing dot or hyphen", () => { + for (const name of ["Ubuntu-", "Ubuntu.", ".Ubuntu", "-Ubuntu"]) { + expect(isValidDistroName(name)).toBe(true); } }); diff --git a/apps/desktop/src/wsl/wslPathParsing.ts b/apps/desktop/src/wsl/wslPathParsing.ts index a1ce99c129d13..1df64f91dac0e 100644 --- a/apps/desktop/src/wsl/wslPathParsing.ts +++ b/apps/desktop/src/wsl/wslPathParsing.ts @@ -9,7 +9,8 @@ export interface WslConfig { } // Literal spaces only; \s would accept controls and corrupt UNC distro paths. -const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; +// "." and ".." would be path segments in a UNC path, not distro names. +const DISTRO_NAME_PATTERN = /^(?!\.{1,2}$)[\w.-](?:[\w .-]*[\w.-])?$/; export function parseWslDistroList(stdout: Buffer): readonly WslDistro[] { const hasUtf16Bom = stdout.length >= 2 && stdout[0] === 0xff && stdout[1] === 0xfe; diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md index 2abb1858b3c60..e7623faa8fe27 100644 --- a/apps/mobile/deps/README.md +++ b/apps/mobile/deps/README.md @@ -1,4 +1,4 @@ -The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers, signed image sources, the `onLinksChange` link list (`renderers`, `resolveImageSource`, `onLinksChange`) and the iOS streaming memory bounds are upstream. The fork carries only changes with an open upstream PR: clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)) and hash-route link fragments ([mity/md4c#454](https://github.com/mity/md4c/pull/454), arriving through [md4c#7](https://github.com/software-mansion-labs/md4c/pull/7)), `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [mity/md4c#477](https://github.com/mity/md4c/pull/477)), GitHub's strikethrough rules for `~` ([md4c#6](https://github.com/software-mansion-labs/md4c/pull/6)), `\(…\)` and `\[…\]` math delimiters ([#1043](https://github.com/software-mansion/enriched-markdown/pull/1043), [md4c#4](https://github.com/software-mansion-labs/md4c/pull/4)), iOS Copy as Markdown keeping bold spans whole ([#1143](https://github.com/software-mansion/enriched-markdown/pull/1143)), line breaks in copied table cells written as `
` ([#1144](https://github.com/software-mansion/enriched-markdown/pull/1144)), and Android measuring text in the span order its view draws it, so code blocks nested in list items no longer make a reply overlap the next message ([#1145](https://github.com/software-mansion/enriched-markdown/pull/1145)). +The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers, signed image sources, the `onLinksChange` link list (`renderers`, `resolveImageSource`, `onLinksChange`) and the iOS streaming memory bounds are upstream. The fork carries only changes with an open upstream PR: clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)) and hash-route link fragments ([mity/md4c#454](https://github.com/mity/md4c/pull/454), arriving through [md4c#7](https://github.com/software-mansion-labs/md4c/pull/7)), `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [mity/md4c#477](https://github.com/mity/md4c/pull/477)), GitHub's strikethrough rules for `~` ([md4c#6](https://github.com/software-mansion-labs/md4c/pull/6)), `\(…\)` and `\[…\]` math delimiters ([#1043](https://github.com/software-mansion/enriched-markdown/pull/1043), [md4c#4](https://github.com/software-mansion-labs/md4c/pull/4)), iOS Copy as Markdown keeping bold spans whole ([#1143](https://github.com/software-mansion/enriched-markdown/pull/1143)), line breaks in copied table cells written as `
` ([#1144](https://github.com/software-mansion/enriched-markdown/pull/1144)), and Android measuring text in the span order its view draws it, so code blocks nested in list items no longer make a reply overlap the next message ([#1145](https://github.com/software-mansion/enriched-markdown/pull/1145)). It also carries one fork-only change: iOS code blocks too big for one backing store draw through tiles, so a tall block with long lines no longer shows an empty pane ([pingdotgg/enriched-markdown#15](https://github.com/pingdotgg/enriched-markdown/pull/15)). To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Keep the package version aligned with Julius's `fork/t3-mobile` branch. Rename the archive to include the first eight characters of the source commit, such as `react-native-enriched-markdown-1.2.0-t3.18-4287c5d3.tgz`, so each build has a distinct dependency path without version bumps. Update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes. diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-0dc7ed81.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-0dc7ed81.tgz new file mode 100644 index 0000000000000..74fcb8768906f Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-0dc7ed81.tgz differ diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-497fbe63.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-497fbe63.tgz deleted file mode 100644 index 2dbfd6d796048..0000000000000 Binary files a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-497fbe63.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz deleted file mode 100644 index f1eb3858e9865..0000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.50.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.50.tgz new file mode 100644 index 0000000000000..74c351ddc3371 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.50.tgz differ diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index d8b8c491f5224..2dbbad891ae1f 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -16,6 +16,7 @@ import android.text.Layout import android.text.Spanned import android.text.TextUtils import android.text.TextWatcher +import android.text.method.Touch import android.text.style.ReplacementSpan import android.util.TypedValue import android.view.DragEvent @@ -636,6 +637,9 @@ internal class SelectionAwareEditText( var textPasteThresholdBytes = 0 var maxInputChars = Int.MAX_VALUE var clipboardFragment = "" + private var touchDownScrollX = 0 + private var touchDownScrollY = 0 + private var touchDownCaret = -1 // Keeps autofill services such as SMS verification codes from writing into the draft. override fun getAutofillType(): Int = View.AUTOFILL_TYPE_NONE @@ -807,6 +811,31 @@ internal class SelectionAwareEditText( } } + // Releasing a drag that scrolled the text makes ArrowKeyMovementMethod pull the caret into view. + // Finish Touch's drag and cancel the release instead, so the caret stays where it was. + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + touchDownScrollX = scrollX + touchDownScrollY = scrollY + touchDownCaret = if (selectionStart == selectionEnd) selectionStart else -1 + } + val releasesScroll = + event.actionMasked == MotionEvent.ACTION_UP && + event.getToolType(0) == MotionEvent.TOOL_TYPE_FINGER && + touchDownCaret >= 0 && + selectionStart == touchDownCaret && + selectionEnd == touchDownCaret && + (scrollX != touchDownScrollX || scrollY != touchDownScrollY) + if (!releasesScroll) return super.onTouchEvent(event) + Touch.onTouchEvent(this, text, event) + val cancel = MotionEvent.obtain(event).apply { action = MotionEvent.ACTION_CANCEL } + return try { + super.onTouchEvent(cancel) + } finally { + cancel.recycle() + } + } + override fun onSelectionChanged(selStart: Int, selEnd: Int) { super.onSelectionChanged(selStart, selEnd) selectionListener?.invoke(selStart, selEnd) diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SecretInputModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SecretInputModule.kt index 1df8c646d4832..4ac5b47a42f32 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SecretInputModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SecretInputModule.kt @@ -7,6 +7,7 @@ import android.os.Build import android.text.Editable import android.text.InputType import android.text.TextWatcher +import android.text.method.PasswordTransformationMethod import android.util.TypedValue import android.view.Gravity import android.view.KeyEvent @@ -42,6 +43,7 @@ class T3SecretInputModule : Module() { view.input.setTextSize(TypedValue.COMPLEX_UNIT_DIP, value) } Prop("editable") { view: T3SecretInputView, value: Boolean -> view.input.isEnabled = value } + Prop("revealed") { view: T3SecretInputView, value: Boolean -> view.setRevealed(value) } Events("onChangeText", "onSubmit") } } @@ -133,6 +135,18 @@ class T3SecretInputView(context: Context, appContext: AppContext) : ExpoView(con applyingValue = false } + /** Swapping the transformation re-sets the text, so it neither echoes to JS nor moves the caret. */ + fun setRevealed(revealed: Boolean) { + val method = if (revealed) null else PasswordTransformationMethod.getInstance() + if (input.transformationMethod == method) return + val start = input.selectionStart + val end = input.selectionEnd + applyingValue = true + input.transformationMethod = method + if (start >= 0 && end >= 0) input.setSelection(start, end) + applyingValue = false + } + fun setCursorColor(color: Int) { input.highlightColor = Color.argb(0x52, Color.red(color), Color.green(color), Color.blue(color)) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index e5c8f6c0fd8f4..c41d2d545ca61 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -67,6 +67,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private var streamedOutput: TerminalOutputBuffer? = null private var cols = 0 private var rows = 0 + private var cellSize = 0 to 0 private var measuredCols = 0 private var measuredRows = 0 private var clearingInput = false @@ -356,25 +357,24 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex applyGrid() } + @Suppress("ComplexCondition") private fun applyGrid() { if (measuredCols <= 0 || measuredRows <= 0 || isCleanedUp) return val shared = sharedGrid.takeIf { it.size == 2 && it[0] > 0 && it[1] > 0 } val nextCols = shared?.get(0)?.coerceAtMost(measuredCols) ?: measuredCols val nextRows = shared?.get(1)?.coerceAtMost(measuredRows) ?: measuredRows - if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return + val nextCellSize = terminalCanvas.cellWidthPx.toInt() to terminalCanvas.cellHeightPx.toInt() + if (nextCols == cols && nextRows == rows && nextCellSize == cellSize && terminalHandle != 0L) { + return + } cols = nextCols rows = nextRows + cellSize = nextCellSize val response = if (terminalHandle == 0L) { createTerminal() ByteArray(0) } else { - GhosttyBridge.nativeResize( - terminalHandle, - cols, - rows, - terminalCanvas.cellWidthPx.toInt(), - terminalCanvas.cellHeightPx.toInt(), - ) + GhosttyBridge.nativeResize(terminalHandle, cols, rows, cellSize.first, cellSize.second) } emitResponse(response) if (!hasPendingBuffer) feedPendingBuffer() @@ -387,8 +387,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex terminalHandle = GhosttyBridge.nativeCreate( cols, rows, - terminalCanvas.cellWidthPx.toInt(), - terminalCanvas.cellHeightPx.toInt(), + cellSize.first, + cellSize.second, foregroundColorValue, backgroundColorValue, cursorColorValue, diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index d8f0b29ebce52..1ed02e6a16cbf 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -285,7 +285,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != fontSize else { return } inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) - resetSurface() + applyConfigInPlace() } } @@ -321,14 +321,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != appearanceScheme else { return } appearance = TerminalAppearanceScheme(value: appearanceScheme) - resetSurface() + applyConfigInPlace() } } var themeConfig: String = "" { didSet { guard oldValue != themeConfig else { return } - resetSurface() + applyConfigInPlace() } } @@ -663,7 +663,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte surfaceCreationFailed = true return } - loadThemeConfig(into: config) + // Creation proceeds without the file, as before; font_size is also set on the surface config. + _ = loadThemeConfig(into: config) ghostty_config_finalize(config) defer { ghostty_config_free(config) } @@ -710,6 +711,27 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte setNeedsLayout() } + // Font and theme change in place: a new surface would replay output and re-answer its queries. + private func applyConfigInPlace() { + guard let app, let surface else { + resetSurface() + return + } + guard let config = ghostty_config_new() else { return } + defer { ghostty_config_free(config) } + let loaded = loadThemeConfig(into: config) + ghostty_config_finalize(config) + ghostty_app_set_color_scheme(app, appearance.ghosttyColorScheme) + ghostty_surface_set_color_scheme(surface, appearance.ghosttyColorScheme) + tickApp() + // Without the file, the config lacks font-size and would reset the surface to defaults. + if loaded { + ghostty_surface_update_config(surface, config) + } + resizeSurface() + setNeedsLayout() + } + private func destroySurface() { // Unblock the io thread before ghostty_surface_free joins it. tickApp() @@ -933,16 +955,16 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte terminalViewport.backgroundColor = backgroundColorValue } - private func loadThemeConfig(into config: ghostty_config_t) { - guard let path = writeThemeConfigFile() else { return } + private func loadThemeConfig(into config: ghostty_config_t) -> Bool { + guard let path = writeThemeConfigFile() else { return false } path.withCString { cString in ghostty_config_load_file(config, cString) } + return true } private func writeThemeConfigFile() -> String? { - guard !themeConfig.isEmpty else { return nil } - let configContents = themeConfig + let configContents = "\(themeConfig)\nfont-size = \(String(format: "%g", Double(fontSize)))\n" let url = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("t3-terminal-theme-\(appearance.rawValue).ghostty") diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a88d3ee4dbb0f..dd1040bfd58a2 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -127,7 +127,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-native": "0.88.1", - "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.18-497fbe63.tgz", + "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.18-0dc7ed81.tgz", "react-native-gesture-handler": "~3.5.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.23.0", @@ -135,7 +135,7 @@ "react-native-pdf-jsi": "6.1.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.11.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.48.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.50.tgz", "react-native-svg": "15.16.1", "react-native-webview": "14.0.1", "react-native-worklets": "0.13.1", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 2fe48bf54d996..e101708898431 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -4,7 +4,7 @@ import { installThreadOutboxDelivery } from "./state/threadOutboxDelivery"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; import { requireNativeModule } from "expo"; -import { useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import { Platform, StatusBar } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; @@ -22,7 +22,7 @@ import { import { pipe } from "effect/Function"; import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; -import { RegistryContext } from "@effect/atom-react"; +import { RegistryContext, useAtomValue } from "@effect/atom-react"; import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet"; import { ThreadGroupEditorHost } from "./features/threads/ThreadGroupEditor"; @@ -53,7 +53,7 @@ import { RootStack, rootPathnameFromState, workspaceLocationFromState } from "./ import { appAtomRegistry } from "./state/atom-registry"; import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts"; import { environmentThreadShells, threadEnvironment } from "./state/threads"; -import { environmentShell } from "./state/shell"; +import { environmentShell, shellCachesReadAtom } from "./state/shell"; import { OverlayPortalHost } from "./components/ui/OverlayPortal"; import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking"; import { hasCloudPublicConfig } from "./features/cloud/publicConfig"; @@ -80,6 +80,12 @@ import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarning import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; +import { + launchSplashHomeRowsPending, + settleLaunchSplashHomeRows, + splashCanHide, + useLaunchSplashHomeRowsPending, +} from "./features/home/home-launch-splash"; import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; import { hydrateWorkspaceSelections, @@ -169,6 +175,8 @@ function handleNavigationStateChange(state: NavigationState | undefined): void { resolvePendingNewTaskPromptLink(state); resolvePendingThreadPromptLink(state); if (!state) return; + // A launch that opens another route has no Home rows for the splash to wait for. + if (state.routes[state.index]?.name !== "Home") settleLaunchSplashHomeRows(); setAgentNotificationPathname(workspaceLocationFromState(state).pathname); recordAppShortcutNavigationState(state); pruneNewTaskFlowStates(state); @@ -208,13 +216,38 @@ disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.ho for (const dispose of appServiceDisposers) dispose(); }); +// Cached shells are local reads that end in a fraction of this time; the limit keeps a failed +// read, or a list that does not lay out, from holding the splash screen. +const SPLASH_WAIT_MS = 1_500; + function SplashScreenCoordinator() { const { isReady } = useAppearancePreferences(); + const shellCachesRead = useAtomValue(shellCachesReadAtom); + const homeRowsPending = useLaunchSplashHomeRowsPending(); + const [waitEnded, setWaitEnded] = useState(false); + const hidden = useRef(false); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts the limit on how long the native splash screen waits for cached shells and their rows + useEffect(() => { + const timer = setTimeout(() => setWaitEnded(true), SPLASH_WAIT_MS); + return () => clearTimeout(timer); + }, []); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once appearance preferences have loaded + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once preferences have loaded and the cached rows are laid out useEffect(() => { - if (isReady) void SplashScreen.hide(); - }, [isReady]); + const canHide = splashCanHide({ + preferencesReady: isReady, + shellCachesRead, + // Home holds the splash in a layout effect of the commit that gives it rows. That can be + // this commit, after this render read the store. + homeRowsPending: homeRowsPending || launchSplashHomeRowsPending(), + waitEnded, + }); + if (!canHide || hidden.current) return; + hidden.current = true; + settleLaunchSplashHomeRows(); + void SplashScreen.hide(); + }, [isReady, shellCachesRead, homeRowsPending, waitEnded]); return null; } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index c85d116a4d0d0..f0d34143c9669 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -505,11 +505,11 @@ const RootStackConfig = createWorkspaceStackNavigator({ Home: createNativeStackScreen({ screen: WorkspaceRootScreen, linking: "", - listeners: { + listeners: ({ navigation }) => ({ transitionStart: ({ data }) => recordHomeTransitionStart(data.closing), - transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing), + transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing, navigation.isFocused()), focus: recordHomeFocus, - }, + }), options: { ...pageRoute({ duoMinimize: "never", root: true }), contentStyle: { backgroundColor: "transparent" }, diff --git a/apps/mobile/src/components/ui/AppBar.android.tsx b/apps/mobile/src/components/ui/AppBar.android.tsx index f647535a1d42c..7318729511805 100644 --- a/apps/mobile/src/components/ui/AppBar.android.tsx +++ b/apps/mobile/src/components/ui/AppBar.android.tsx @@ -2,6 +2,7 @@ import { type RefObject, use, useRef, useState } from "react"; import { Keyboard, type TextInputInstance, View } from "react-native"; import { useHardwareBack } from "../../lib/backHandler"; import { useToolbarLayout } from "./useToolbarLayout"; +import { BottomSheetContext } from "./BottomSheetContext"; import { RoutedFormSheetContext } from "./RoutedFormSheetContext"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useSidebarToggle } from "../../features/layout/useSidebarToggle"; @@ -82,12 +83,14 @@ function AppBarView(props: AppBarProps & { readonly searchState?: SearchState }) const inWorkspace = useHasWorkspaceAppBar(); const sidebarToggle = useSidebarToggle(); const closeInspector = use(InspectorCloseContext); + // A bottom sheet owns the pull at its top, so refresh needs a button there. + const inBottomSheet = use(BottomSheetContext); const inline = search?.mode === "inline"; const searching = searchState?.searching === true; const actions = props.refresh ? [ ...(props.actions ?? []), - { ...props.refresh, icon: "arrow.clockwise" as const, overflowOnly: true }, + { ...props.refresh, icon: "arrow.clockwise" as const, overflowOnly: !inBottomSheet }, ] : props.actions; const openSearch = () => searchState?.open(); diff --git a/apps/mobile/src/components/ui/AppBar.tsx b/apps/mobile/src/components/ui/AppBar.tsx index 7f76d91066130..b691faa300dae 100644 --- a/apps/mobile/src/components/ui/AppBar.tsx +++ b/apps/mobile/src/components/ui/AppBar.tsx @@ -1,8 +1,8 @@ import { Toolbar } from "./Toolbar"; import { AppActivityIndicator } from "./AppActivityIndicator"; -import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; -import { NavigationContext } from "@react-navigation/native"; -import { use, useId, useLayoutEffect } from "react"; +import type { HeaderBarButtonMailSearchToolbarItem, SearchBarCommands } from "react-native-screens"; +import { NavigationContext, NavigationRouteContext } from "@react-navigation/native"; +import { use, useId, useLayoutEffect, useRef, useSyncExternalStore } from "react"; import { View } from "react-native"; import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; import { ScreenHeaderButton } from "./ScreenHeaderButton"; @@ -63,6 +63,17 @@ export function AppBar(props: AppBarProps) { ? { ...props.backInSplitView, onPress: splitBackPress } : null; const navigation = use(NavigationContext); + const route = use(NavigationRouteContext); + // A thread opened from another thread sits on it, not beside the list, so it keeps Back. + const threadBelow = useSyncExternalStore( + (onChange) => navigation?.addListener("state", onChange) ?? (() => {}), + () => { + const routes = navigation?.getState()?.routes; + const index = routes?.findIndex((candidate) => candidate.key === route?.key) ?? -1; + return route?.name === "Thread" && index > 0 && routes?.[index - 1]?.name === "Thread"; + }, + ); + const listBeside = layout.usesSplitView && props.onBackToList !== undefined && !threadBelow; const rootBack = !layout.usesSplitView && props.onBackToList && navigation?.canGoBack() === false ? props.onBackToList @@ -78,6 +89,19 @@ export function AppBar(props: AppBarProps) { const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const { search, menus } = props; const menu = menus?.[0]; + const searchBarRef = useRef(null); + const nativeSearchText = useRef(search?.value ?? ""); + const searchValue = search?.value; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the native search bar keeps its own text; a value the caller changes (a reset) has to be pushed into it + useLayoutEffect(() => { + if (searchValue === undefined || searchValue === nativeSearchText.current) return; + nativeSearchText.current = searchValue; + searchBarRef.current?.setText(searchValue); + }, [searchValue]); + const reportSearchText = (text: string) => { + nativeSearchText.current = text; + search?.onChangeText(text); + }; const compactSearch = search !== undefined && (search.compactToolbar ?? !layout.usesSplitView) && @@ -100,7 +124,7 @@ export function AppBar(props: AppBarProps) { headerBackVisible: props.onClose === undefined && splitBack === null && - !(layout.usesSplitView && props.onBackToList) && + !listBeside && !(props.guardBack && props.onBack), ...(props.guardBack ? { gestureEnabled: false } : undefined), title: props.title, @@ -142,8 +166,9 @@ export function AppBar(props: AppBarProps) { ? { obscureBackground: false } : undefined), placeholder: search.placeholder, - onChangeText: (event) => search.onChangeText(event.nativeEvent.text), - onCancelButtonPress: () => search.onChangeText(""), + ref: searchBarRef, + onChangeText: (event) => reportSearchText(event.nativeEvent.text), + onCancelButtonPress: () => reportSearchText(""), }, } : undefined), diff --git a/apps/mobile/src/components/ui/AppBar.types.ts b/apps/mobile/src/components/ui/AppBar.types.ts index 88d711be71544..071b0609606e3 100644 --- a/apps/mobile/src/components/ui/AppBar.types.ts +++ b/apps/mobile/src/components/ui/AppBar.types.ts @@ -78,7 +78,10 @@ export interface AppBarProps { readonly titleContent?: ReactNode; readonly subtitle?: string | null; readonly actions?: ReadonlyArray; - /** Pull-to-refresh is the gesture on both platforms; Android also lists this in the overflow menu. */ + /** + * Pull-to-refresh is the gesture on both platforms. Android also lists this in the overflow + * menu, or shows it as a button in a bottom sheet, where the sheet owns the pull. + */ readonly refresh?: Pick; readonly menus?: ReadonlyArray; readonly search?: AppBarSearch; diff --git a/apps/mobile/src/components/ui/BottomSheetContext.ts b/apps/mobile/src/components/ui/BottomSheetContext.ts new file mode 100644 index 0000000000000..c635f860e02de --- /dev/null +++ b/apps/mobile/src/components/ui/BottomSheetContext.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; + +/** True inside a draggable bottom sheet, which owns the downward drag at the top of its content. */ +export const BottomSheetContext = createContext(false); diff --git a/apps/mobile/src/components/ui/ContentBand.android.tsx b/apps/mobile/src/components/ui/ContentBand.android.tsx new file mode 100644 index 0000000000000..5a1eb83543024 --- /dev/null +++ b/apps/mobile/src/components/ui/ContentBand.android.tsx @@ -0,0 +1,46 @@ +import { View } from "react-native"; + +import { cn } from "../../lib/cn"; +import { AppActivityIndicator } from "./AppActivityIndicator"; +import { AppText as Text } from "./AppText"; +import type { ContentBandProps, ContentStatusProps } from "./ContentBand.types"; +export type { ContentBandProps, ContentStatusProps } from "./ContentBand.types"; + +/** A rounded card floating in the content's margin. */ +export function ContentBand(props: ContentBandProps) { + return ( + + {props.children} + + ); +} + +/** Material centers an empty state in the space it has. */ +export function ContentStatus(props: ContentStatusProps) { + if (props.loading) { + return ( + + + {props.detail} + + ); + } + return ( + + {props.title ? ( + {props.title} + ) : null} + + {props.detail} + + + ); +} + +export const contentStatusContainerStyle = { flexGrow: 1, justifyContent: "center" } as const; diff --git a/apps/mobile/src/components/ui/ContentBand.tsx b/apps/mobile/src/components/ui/ContentBand.tsx new file mode 100644 index 0000000000000..cb6bca78fdcae --- /dev/null +++ b/apps/mobile/src/components/ui/ContentBand.tsx @@ -0,0 +1,44 @@ +import { View } from "react-native"; + +import { cn } from "../../lib/cn"; +import { AppActivityIndicator } from "./AppActivityIndicator"; +import { AppText as Text } from "./AppText"; +import type { ContentBandProps, ContentStatusProps } from "./ContentBand.types"; +export type { ContentBandProps, ContentStatusProps } from "./ContentBand.types"; + +/** A full-width band above or in place of content, ruled off from what follows. */ +export function ContentBand(props: ContentBandProps) { + return ( + + {props.children} + + ); +} + +export function ContentStatus(props: ContentStatusProps) { + if (props.loading) { + return ( + + + {props.detail} + + ); + } + return ( + + {props.title ? ( + {props.title} + ) : null} + {props.detail} + + ); +} + +/** The scroll content style for a ContentStatus with nothing else in the scroll view. */ +export const contentStatusContainerStyle = undefined; diff --git a/apps/mobile/src/components/ui/ContentBand.types.ts b/apps/mobile/src/components/ui/ContentBand.types.ts new file mode 100644 index 0000000000000..95843c3e07651 --- /dev/null +++ b/apps/mobile/src/components/ui/ContentBand.types.ts @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +export interface ContentBandProps { + readonly tone?: "card" | "warning"; + /** "roomy" for a band holding a block of content, such as a raw patch. */ + readonly spacing?: "compact" | "roomy"; + readonly children: ReactNode; +} + +/** A status at the top of otherwise empty content, such as "No changes" or a loading diff. */ +export interface ContentStatusProps { + readonly title?: string; + readonly detail: string; + readonly loading?: boolean; +} diff --git a/apps/mobile/src/components/ui/FitSheet.android.tsx b/apps/mobile/src/components/ui/FitSheet.android.tsx new file mode 100644 index 0000000000000..a797e87a8a65d --- /dev/null +++ b/apps/mobile/src/components/ui/FitSheet.android.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from "react"; +import { ScrollView, useWindowDimensions, View } from "react-native"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { fitSheetBottomInset, type FitSheetProps } from "./FitSheet.types"; +import { ScreenContent } from "./ScreenContent"; +export type { FitSheetProps } from "./FitSheet.types"; + +/** The route sizes the sheet to this content, so the column shrinks to fit and stops short of the top. */ +export function FitSheet({ + header, + footer, + avoidsKeyboard, + children, + contentContainerClassName = "gap-2 p-2", + contentContainerStyle, + ...scrollProps +}: FitSheetProps) { + const insets = useSafeAreaInsets(); + return ( + // Material sheet drags follow the first nested-scrolling native view, and a header's + // Compose button is one, so the content renders first in a reversed column. + + + + {children} + {/* Partial-height sheets keep submit actions inside the scrolling form. */} + {footer ? ( + + {footer} + + ) : null} + + + {header} + + ); +} + +function SheetContainer(props: { + readonly avoidsKeyboard?: boolean; + readonly children: ReactNode; +}) { + const { height } = useWindowDimensions(); + const style = { maxHeight: height * 0.92 }; + return props.avoidsKeyboard ? ( + + {props.children} + + ) : ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/FitSheet.tsx b/apps/mobile/src/components/ui/FitSheet.tsx new file mode 100644 index 0000000000000..f783dbcd964ae --- /dev/null +++ b/apps/mobile/src/components/ui/FitSheet.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import { ScrollView, View } from "react-native"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { fitSheetBottomInset, type FitSheetProps } from "./FitSheet.types"; +import { ScreenContent } from "./ScreenContent"; +export type { FitSheetProps } from "./FitSheet.types"; + +export function FitSheet({ + header, + footer, + avoidsKeyboard, + children, + contentContainerClassName = "gap-4 px-5 pt-2", + contentContainerStyle, + ...scrollProps +}: FitSheetProps) { + const insets = useSafeAreaInsets(); + return ( + + {header} + + + {children} + + {footer ? ( + + {footer} + + ) : null} + + + ); +} + +function SheetContainer(props: { + readonly avoidsKeyboard?: boolean; + readonly children: ReactNode; +}) { + return props.avoidsKeyboard ? ( + + {props.children} + + ) : ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/FitSheet.types.ts b/apps/mobile/src/components/ui/FitSheet.types.ts new file mode 100644 index 0000000000000..a47fb124ea36c --- /dev/null +++ b/apps/mobile/src/components/ui/FitSheet.types.ts @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import type { ScrollViewProps } from "react-native"; + +/** The body of a `sheetRoute({ androidFit: true })` screen: a header over one scrolling form. */ +export interface FitSheetProps extends Omit< + ScrollViewProps, + "children" | "className" | "nestedScrollEnabled" | "contentInsetAdjustmentBehavior" +> { + readonly header: ReactNode; + readonly children: ReactNode; + /** Submit actions, kept reachable below the form. */ + readonly footer?: ReactNode; + /** Lifts the sheet above the keyboard, for forms whose inputs sit low. */ + readonly avoidsKeyboard?: boolean; +} + +export const fitSheetBottomInset = (bottom: number) => Math.max(bottom, 18) + 18; diff --git a/apps/mobile/src/components/ui/FormSection.android.tsx b/apps/mobile/src/components/ui/FormSection.android.tsx new file mode 100644 index 0000000000000..8f68b99477e10 --- /dev/null +++ b/apps/mobile/src/components/ui/FormSection.android.tsx @@ -0,0 +1,23 @@ +import { View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "./AppText"; +import type { FormFieldProps, FormSectionProps } from "./FormSection.types"; +export type { FormFieldProps, FormSectionProps } from "./FormSection.types"; + +export function FormSection(props: FormSectionProps) { + return ( + + {props.title} + {props.children} + + ); +} + +export function FormField({ label, ...props }: FormFieldProps) { + return ( + <> + {label ? {label} : null} + + + ); +} diff --git a/apps/mobile/src/components/ui/FormSection.tsx b/apps/mobile/src/components/ui/FormSection.tsx new file mode 100644 index 0000000000000..7f3e7a5d80634 --- /dev/null +++ b/apps/mobile/src/components/ui/FormSection.tsx @@ -0,0 +1,20 @@ +import { View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "./AppText"; +import type { FormFieldProps, FormSectionProps } from "./FormSection.types"; +export type { FormFieldProps, FormSectionProps } from "./FormSection.types"; + +export function FormSection(props: FormSectionProps) { + return ( + + + {props.title} + + {props.children} + + ); +} + +export function FormField({ label: _label, ...props }: FormFieldProps) { + return ; +} diff --git a/apps/mobile/src/components/ui/FormSection.types.ts b/apps/mobile/src/components/ui/FormSection.types.ts new file mode 100644 index 0000000000000..4ef7015aa98cb --- /dev/null +++ b/apps/mobile/src/components/ui/FormSection.types.ts @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +import type { AppTextInputProps } from "./AppText"; + +/** A titled card holding a short form: its fields and the action that submits them. */ +export interface FormSectionProps { + readonly title: string; + readonly children: ReactNode; +} + +export interface FormFieldProps extends Omit { + /** Shown where the platform labels fields; the placeholder carries it elsewhere. */ + readonly label?: string; + readonly accessibilityLabel: string; +} diff --git a/apps/mobile/src/components/ui/KeyboardSheetBody.android.tsx b/apps/mobile/src/components/ui/KeyboardSheetBody.android.tsx new file mode 100644 index 0000000000000..8f889fd9682d1 --- /dev/null +++ b/apps/mobile/src/components/ui/KeyboardSheetBody.android.tsx @@ -0,0 +1,41 @@ +import { View } from "react-native"; +import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import type { KeyboardSheetBodyProps } from "./KeyboardSheetBody.types"; +export type { KeyboardSheetBodyProps } from "./KeyboardSheetBody.types"; + +const TOOLBAR_CLEARANCE = 72; + +/** The toolbar sticks to the keyboard on its own, over the body's bottom edge. */ +export function KeyboardSheetBody(props: KeyboardSheetBodyProps) { + const insets = useSafeAreaInsets(); + return ( + <> + + + {props.children} + + + {props.toolbar ? ( + + + {props.toolbar} + + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/ui/KeyboardSheetBody.tsx b/apps/mobile/src/components/ui/KeyboardSheetBody.tsx new file mode 100644 index 0000000000000..c09e9f5ce7270 --- /dev/null +++ b/apps/mobile/src/components/ui/KeyboardSheetBody.tsx @@ -0,0 +1,30 @@ +import { View } from "react-native"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import type { KeyboardSheetBodyProps } from "./KeyboardSheetBody.types"; +import { useBarOverlap } from "./useBarInset"; +export type { KeyboardSheetBodyProps } from "./KeyboardSheetBody.types"; + +/** A sheet's body that stays above the keyboard, with its toolbar between the two. */ +export function KeyboardSheetBody(props: KeyboardSheetBodyProps) { + const insets = useSafeAreaInsets(); + // The sheet's glass bar floats over its content, and only scroll views inset for it on their own. + const barOverlap = useBarOverlap(); + return ( + + + {props.children} + + {props.toolbar ? ( + {props.toolbar} + ) : null} + + ); +} diff --git a/apps/mobile/src/components/ui/KeyboardSheetBody.types.ts b/apps/mobile/src/components/ui/KeyboardSheetBody.types.ts new file mode 100644 index 0000000000000..00c2ac30f3c6b --- /dev/null +++ b/apps/mobile/src/components/ui/KeyboardSheetBody.types.ts @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +export interface KeyboardSheetBodyProps { + readonly children: ReactNode; + /** A row of actions that rides the keyboard, such as attach and send. */ + readonly toolbar?: ReactNode; +} diff --git a/apps/mobile/src/components/ui/PaneListItem.android.tsx b/apps/mobile/src/components/ui/PaneListItem.android.tsx new file mode 100644 index 0000000000000..bcc227457b365 --- /dev/null +++ b/apps/mobile/src/components/ui/PaneListItem.android.tsx @@ -0,0 +1,21 @@ +import { Pressable } from "react-native"; + +import { cn } from "../../lib/cn"; +import type { PaneListItemProps } from "./PaneListItem.types"; +export type { PaneListItemProps } from "./PaneListItem.types"; + +export function PaneListItem(props: PaneListItemProps) { + return ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/PaneListItem.tsx b/apps/mobile/src/components/ui/PaneListItem.tsx new file mode 100644 index 0000000000000..4a43468729172 --- /dev/null +++ b/apps/mobile/src/components/ui/PaneListItem.tsx @@ -0,0 +1,22 @@ +import { Pressable } from "react-native"; + +import type { PaneListItemProps } from "./PaneListItem.types"; +export type { PaneListItemProps } from "./PaneListItem.types"; + +/** A selectable entry in a pane's navigation list, such as a changed file. */ +export function PaneListItem(props: PaneListItemProps) { + return ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/PaneListItem.types.ts b/apps/mobile/src/components/ui/PaneListItem.types.ts new file mode 100644 index 0000000000000..2f661883ea0ba --- /dev/null +++ b/apps/mobile/src/components/ui/PaneListItem.types.ts @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +export interface PaneListItemProps { + readonly selected: boolean; + readonly onPress: () => void; + readonly children: ReactNode; +} diff --git a/apps/mobile/src/components/ui/PaneSheet.android.tsx b/apps/mobile/src/components/ui/PaneSheet.android.tsx new file mode 100644 index 0000000000000..c0e572f534517 --- /dev/null +++ b/apps/mobile/src/components/ui/PaneSheet.android.tsx @@ -0,0 +1,26 @@ +import { View } from "react-native"; +import { FormSheet } from "react-native-screens"; +import { useResolveClassNames } from "uniwind"; + +import { BottomSheetContext } from "./BottomSheetContext"; +import type { PaneSheetProps } from "./PaneSheet.types"; +export type { PaneSheetProps } from "./PaneSheet.types"; + +/** A native sheet holding a pane that draws its own bar, which runs up under the drawn grabber. */ +export function PaneSheet({ children, ...props }: PaneSheetProps) { + const containerStyle = useResolveClassNames("bg-header"); + return ( + + + {/* Android FormSheet does not bound flex children. */} + + {children} + + + + + ); +} diff --git a/apps/mobile/src/components/ui/PaneSheet.tsx b/apps/mobile/src/components/ui/PaneSheet.tsx new file mode 100644 index 0000000000000..422635120cc62 --- /dev/null +++ b/apps/mobile/src/components/ui/PaneSheet.tsx @@ -0,0 +1,21 @@ +import { View } from "react-native"; +import { FormSheet } from "react-native-screens"; +import { useResolveClassNames } from "uniwind"; + +import { BottomSheetContext } from "./BottomSheetContext"; +import type { PaneSheetProps } from "./PaneSheet.types"; +export type { PaneSheetProps } from "./PaneSheet.types"; + +/** A native sheet holding a pane that draws its own bar. */ +export function PaneSheet({ children, ...props }: PaneSheetProps) { + const containerStyle = useResolveClassNames("bg-sheet"); + return ( + + + + {children} + + + + ); +} diff --git a/apps/mobile/src/components/ui/PaneSheet.types.ts b/apps/mobile/src/components/ui/PaneSheet.types.ts new file mode 100644 index 0000000000000..3b9bc8634133f --- /dev/null +++ b/apps/mobile/src/components/ui/PaneSheet.types.ts @@ -0,0 +1,7 @@ +import type { ComponentProps } from "react"; +import type { FormSheet } from "react-native-screens"; + +export type PaneSheetProps = Omit< + ComponentProps, + "prefersGrabberVisible" | "preferredCornerRadius" | "nativeContainerStyle" +>; diff --git a/apps/mobile/src/components/ui/RefreshControl.android.tsx b/apps/mobile/src/components/ui/RefreshControl.android.tsx new file mode 100644 index 0000000000000..e83dd26633989 --- /dev/null +++ b/apps/mobile/src/components/ui/RefreshControl.android.tsx @@ -0,0 +1,22 @@ +import { use } from "react"; +// oxlint-disable-next-line eslint/no-restricted-imports -- The wrapper every screen must use. +import { RefreshControl as NativeRefreshControl } from "react-native"; +import { View, type RefreshControlProps } from "react-native"; + +import { BottomSheetContext } from "./BottomSheetContext"; + +/** + * A bottom sheet takes the pull at the top, and Material reads the swipe-refresh layout as unable + * to scroll up, so the sheet collapses instead of scrolling back. Sheets get a plain wrapper that + * keeps the layout style the scroll view hands its refresh control. + */ +export function RefreshControl(props: RefreshControlProps) { + if (use(BottomSheetContext)) { + return ( + + {props.children} + + ); + } + return ; +} diff --git a/apps/mobile/src/components/ui/RefreshControl.tsx b/apps/mobile/src/components/ui/RefreshControl.tsx new file mode 100644 index 0000000000000..02b2b648b7bd1 --- /dev/null +++ b/apps/mobile/src/components/ui/RefreshControl.tsx @@ -0,0 +1,2 @@ +// oxlint-disable-next-line eslint/no-restricted-imports -- The wrapper every screen must use. +export { RefreshControl } from "react-native"; diff --git a/apps/mobile/src/components/ui/SecretRevealButton.tsx b/apps/mobile/src/components/ui/SecretRevealButton.tsx new file mode 100644 index 0000000000000..2ba628eec1814 --- /dev/null +++ b/apps/mobile/src/components/ui/SecretRevealButton.tsx @@ -0,0 +1,25 @@ +import { Pressable } from "react-native"; + +import { SymbolView } from "./AppSymbol"; + +/** Eye toggle that shows or hides a secret field's typed text. */ +export function SecretRevealButton(props: { + readonly revealed: boolean; + readonly onPress: () => void; +}) { + return ( + + + + ); +} diff --git a/apps/mobile/src/components/ui/SecretTextInput.android.tsx b/apps/mobile/src/components/ui/SecretTextInput.android.tsx index f93b091cf9b58..9a9f98bd55c14 100644 --- a/apps/mobile/src/components/ui/SecretTextInput.android.tsx +++ b/apps/mobile/src/components/ui/SecretTextInput.android.tsx @@ -1,8 +1,10 @@ import { requireNativeView } from "expo"; +import { useState } from "react"; import { processColor, View, type NativeSyntheticEvent, type ViewProps } from "react-native"; import { flattenThemeColor } from "../../lib/mobileTheme"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { SecretRevealButton } from "./SecretRevealButton"; import type { SecretTextInputProps } from "./SecretTextInput.types"; interface NativeSecretInputProps extends ViewProps { @@ -14,6 +16,7 @@ interface NativeSecretInputProps extends ViewProps { readonly cursorColor: number; readonly fontSize: number; readonly editable: boolean; + readonly revealed: boolean; readonly onChangeText: (event: NativeSyntheticEvent<{ readonly value: string }>) => void; readonly onSubmit: (event: NativeSyntheticEvent<{ readonly value: string }>) => void; } @@ -36,8 +39,9 @@ function nativeColor(value: string) { export function SecretTextInput(props: SecretTextInputProps) { const theme = useUniwindTheme(); const surface = theme["--color-input"]; + const [revealed, setRevealed] = useState(false); return ( - + props.onChangeText(event.nativeEvent.value)} onSubmit={(event) => props.onSubmitEditing(event.nativeEvent.value)} - style={{ height: LINE_HEIGHT }} + style={{ flex: 1, height: LINE_HEIGHT }} /> + setRevealed(!revealed)} /> ); } diff --git a/apps/mobile/src/components/ui/SecretTextInput.tsx b/apps/mobile/src/components/ui/SecretTextInput.tsx index ed8bba58daaa7..643fe5acbb63d 100644 --- a/apps/mobile/src/components/ui/SecretTextInput.tsx +++ b/apps/mobile/src/components/ui/SecretTextInput.tsx @@ -1,24 +1,35 @@ +import { useState } from "react"; +import { View } from "react-native"; + import { AppTextInput } from "./AppText"; +import { SecretRevealButton } from "./SecretRevealButton"; import type { SecretTextInputProps } from "./SecretTextInput.types"; /** Masked field for a value that must stay out of password managers and suggestions. */ export function SecretTextInput(props: SecretTextInputProps) { + const [revealed, setRevealed] = useState(false); return ( - props.onSubmitEditing(event.nativeEvent.text)} - /> + + props.onSubmitEditing(event.nativeEvent.text)} + /> + + setRevealed(!revealed)} /> + + ); } diff --git a/apps/mobile/src/components/ui/Sheet.android.tsx b/apps/mobile/src/components/ui/Sheet.android.tsx index d4eac0cb94c1b..29efc04d810df 100644 --- a/apps/mobile/src/components/ui/Sheet.android.tsx +++ b/apps/mobile/src/components/ui/Sheet.android.tsx @@ -3,6 +3,7 @@ import { View } from "react-native"; import { FormSheet } from "react-native-screens"; import { useResolveClassNames } from "uniwind"; import { AppBar } from "./AppBar"; +import { BottomSheetContext } from "./BottomSheetContext"; import { RoutedFormSheetContext } from "./RoutedFormSheetContext"; import { ScreenContent } from "./ScreenContent"; import type { SheetProps } from "./Sheet.types"; @@ -21,22 +22,24 @@ export function Sheet(props: SheetProps) { onDismiss={props.onClose} onNativeDismiss={props.onClose} > - - {/* Android FormSheet does not bound flex children. Material drags target the first scroll view, so place content before the header. */} - - {props.children} - setIsOpen(false)} - hideBottomBorder - /> - - - + + + {/* Android FormSheet does not bound flex children. */} + + setIsOpen(false)} + hideBottomBorder + /> + {props.children} + + + + ); } diff --git a/apps/mobile/src/components/ui/SheetIntro.android.tsx b/apps/mobile/src/components/ui/SheetIntro.android.tsx new file mode 100644 index 0000000000000..0dd6ac142b4f9 --- /dev/null +++ b/apps/mobile/src/components/ui/SheetIntro.android.tsx @@ -0,0 +1,17 @@ +import { View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import type { SheetIntroProps } from "./SheetIntro.types"; +export type { SheetIntroProps } from "./SheetIntro.types"; + +/** Material sheets lead with the question itself, so the eyebrow is left out. */ +export function SheetIntro(props: SheetIntroProps) { + return ( + + {props.title} + + {props.description} + + + ); +} diff --git a/apps/mobile/src/components/ui/SheetIntro.tsx b/apps/mobile/src/components/ui/SheetIntro.tsx new file mode 100644 index 0000000000000..a3b8e507445dc --- /dev/null +++ b/apps/mobile/src/components/ui/SheetIntro.tsx @@ -0,0 +1,21 @@ +import { View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import type { SheetIntroProps } from "./SheetIntro.types"; +export type { SheetIntroProps } from "./SheetIntro.types"; + +export function SheetIntro(props: SheetIntroProps) { + return ( + + {props.eyebrow ? ( + + {props.eyebrow} + + ) : null} + {props.title} + + {props.description} + + + ); +} diff --git a/apps/mobile/src/components/ui/SheetIntro.types.ts b/apps/mobile/src/components/ui/SheetIntro.types.ts new file mode 100644 index 0000000000000..d9ed545d6bed5 --- /dev/null +++ b/apps/mobile/src/components/ui/SheetIntro.types.ts @@ -0,0 +1,7 @@ +/** The lead of a decision sheet: what is being asked, above the choices. */ +export interface SheetIntroProps { + /** A short kicker such as "Confirm", where the platform uses one. */ + readonly eyebrow?: string; + readonly title: string; + readonly description: string; +} diff --git a/apps/mobile/src/components/ui/SheetPane.android.tsx b/apps/mobile/src/components/ui/SheetPane.android.tsx new file mode 100644 index 0000000000000..7f707a9760927 --- /dev/null +++ b/apps/mobile/src/components/ui/SheetPane.android.tsx @@ -0,0 +1,13 @@ +import { View } from "react-native"; + +import type { SheetPaneProps } from "./SheetPane.types"; +export type { SheetPaneProps } from "./SheetPane.types"; + +/** The root of a pane or sheet: the bar's color, so ScreenContent's card reads as set into it. */ +export function SheetPane(props: SheetPaneProps) { + return ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/SheetPane.tsx b/apps/mobile/src/components/ui/SheetPane.tsx new file mode 100644 index 0000000000000..f420964034f49 --- /dev/null +++ b/apps/mobile/src/components/ui/SheetPane.tsx @@ -0,0 +1,16 @@ +import { View } from "react-native"; + +import type { SheetPaneProps } from "./SheetPane.types"; +export type { SheetPaneProps } from "./SheetPane.types"; + +/** The root of a pane or sheet that draws an AppBar above its ScreenContent. */ +export function SheetPane(props: SheetPaneProps) { + return ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/SheetPane.types.ts b/apps/mobile/src/components/ui/SheetPane.types.ts new file mode 100644 index 0000000000000..743ff1cae1326 --- /dev/null +++ b/apps/mobile/src/components/ui/SheetPane.types.ts @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +export interface SheetPaneProps { + /** The iOS surface under the bar: a sheet's, or the screen's where the pane sits in a column. */ + readonly surface?: "sheet" | "screen"; + readonly children: ReactNode; +} diff --git a/apps/mobile/src/components/ui/listMetrics.android.ts b/apps/mobile/src/components/ui/listMetrics.android.ts new file mode 100644 index 0000000000000..63ded41c1514e --- /dev/null +++ b/apps/mobile/src/components/ui/listMetrics.android.ts @@ -0,0 +1,10 @@ +/** Material's 48dp touch target and 56dp one-line list item. */ +export const ROW_MIN_HEIGHT = { control: 48, item: 56 } as const; + +/** Android sheets span the window, so a list stops at a readable width on a tablet or fold. */ +export const READABLE_WIDTH_STYLE = { width: "100%", maxWidth: 720, alignSelf: "center" } as const; + +/** Sections are cards with their own padding, set close in from ScreenContent's edges. */ +export function groupedListInsets(_inInspector: boolean) { + return { paddingHorizontal: 8, gap: 8 }; +} diff --git a/apps/mobile/src/components/ui/listMetrics.ts b/apps/mobile/src/components/ui/listMetrics.ts new file mode 100644 index 0000000000000..c7f8e2f2d2216 --- /dev/null +++ b/apps/mobile/src/components/ui/listMetrics.ts @@ -0,0 +1,10 @@ +/** Minimum heights of tappable rows: a header control, and a list item. */ +export const ROW_MIN_HEIGHT = { control: 44, item: 44 } as const; + +/** Keeps a list at a readable width. iOS sheets and columns already bound it. */ +export const READABLE_WIDTH_STYLE = undefined; + +/** Insets for a scroll view of grouped list sections. */ +export function groupedListInsets(inInspector: boolean) { + return { paddingHorizontal: inInspector ? 12 : 20, gap: 14 }; +} diff --git a/apps/mobile/src/components/ui/useBarInset.ts b/apps/mobile/src/components/ui/useBarInset.ts index f00b64c8e0b70..3a917d67dee4d 100644 --- a/apps/mobile/src/components/ui/useBarInset.ts +++ b/apps/mobile/src/components/ui/useBarInset.ts @@ -65,3 +65,18 @@ export function useSearchToolbarInset(search: AppBarSearch | undefined): number ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET : 0; } + +/** Where a sheet's scrolling content ends: clear of the bottom safe area, with a little room. */ +export function useSheetContentBottomInset(): number { + const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + return Platform.OS === "ios" + ? (columnMetrics?.safeArea.bottom ?? insets.bottom) + 8 + : Math.max(insets.bottom, 18) + 18; +} + +/** The bottom safe area a list in a sheet pads itself: Android's, which its sheets don't adjust for. */ +export function useUnadjustedBottomInset(): number { + const insets = useSafeAreaInsets(); + return Platform.OS === "android" ? insets.bottom : 0; +} diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index e9a24552ce369..1205c746e9fa1 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -24,6 +24,7 @@ import * as Fiber from "effect/Fiber"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import * as MobileDatabase from "../persistence/mobile-database"; import { make } from "./environment-cache-store"; @@ -567,7 +568,7 @@ describe("mobile SQLite environment cache store", () => { }), ); - it.live("returns cached threads before their pull request links, which load after", () => + it.effect("returns cached threads before their pull request links, which load after", () => Effect.gen(function* () { const memory = makeDatabase(); const store = yield* make().pipe( @@ -592,12 +593,13 @@ describe("mobile SQLite environment cache store", () => { const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); expect(shell.threads.map((thread) => thread.pullRequests)).toEqual([undefined, undefined]); - const links = yield* shell.loadPullRequests!; - expect([...links]).toEqual([[THREAD_ID, [link]]]); + const links = yield* Effect.forkChild(shell.loadPullRequests!); + yield* TestClock.adjust("1 millis"); + expect([...(yield* Fiber.join(links))]).toEqual([[THREAD_ID, [link]]]); }), ); - it.live("keeps cached threads when their pull request links cannot be read", () => { + it.effect("keeps cached threads when their pull request links cannot be read", () => { const messages: Array = []; const logger = Logger.make(({ message }) => { messages.push(message); @@ -633,7 +635,9 @@ describe("mobile SQLite environment cache store", () => { const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); expect(shell.threads.map((thread) => thread.id)).toEqual([THREAD_ID, readable.id]); - expect([...(yield* shell.loadPullRequests!)]).toEqual([[readable.id, [link]]]); + const links = yield* Effect.forkChild(shell.loadPullRequests!); + yield* TestClock.adjust("1 millis"); + expect([...(yield* Fiber.join(links))]).toEqual([[readable.id, [link]]]); expect(messages).toHaveLength(1); expect(memory.removed).toEqual([]); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index 2f88de1432a08..fa6f4402890fc 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -40,6 +40,8 @@ const layerProvidedClientConnection = layerSnapshotLoader.pipe( usageLimitSources: true, usageLimitsCommand: true, providerCatalogsOnDemand: true, + providerCatalogDigests: true, + providerStatusDeltas: true, }), ), Layer.provideMerge( diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index fd3ce42403c8a..4a1c77371b86b 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -16,7 +16,8 @@ import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { AppBar } from "../../components/ui/AppBar"; import { SymbolView } from "../../components/ui/AppSymbol"; import { useMemo, useRef, type ComponentProps } from "react"; -import { RefreshControl, useWindowDimensions, View } from "react-native"; +import { useWindowDimensions, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { GestureDetector, useNativeGesture } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx index d83852f260466..b312eeb645346 100644 --- a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx +++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx @@ -35,16 +35,33 @@ type BrowserPreviewRouteScreenProps = StaticScreenProps<{ export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenProps) { const navigation = useNavigation(); const onClose = () => navigation.goBack(); + const { environmentId, threadId } = route.params; + if (!environmentId?.trim() || !threadId?.trim()) { + return ; + } return ( ); } +function BrowserUnavailableScreen({ onClose }: { readonly onClose: () => void }) { + const insets = useSafeAreaInsets(); + const contentTopInset = useContentTopInset(); + return ( + + + + ); +} + function BrowserPreviewScreen({ environmentId, threadId, diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index 3fae93f483bcc..d4a4690dbaf4b 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -405,7 +405,7 @@ function PreviewStreamDocumentView({ onPictureInPicture?.(message, message.detail); return; case "status": - clearBootstrapTimer(); + if (message.status !== "connecting") clearBootstrapTimer(); if (message.status === "error") { fail(message.detail ?? "Browser stream failed."); return; diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 463c11aaf6485..3913eaa47557b 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -5,7 +5,8 @@ import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScroll import { useAuth } from "@clerk/expo"; import { useNavigation } from "@react-navigation/native"; import { useState } from "react"; -import { RefreshControl, View } from "react-native"; +import { View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx index 03b6daaa4e715..71de00071fda8 100644 --- a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -7,7 +7,8 @@ import type { MenuAction } from "@react-native-menu/menu"; import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { type ReactNode, useRef, useState } from "react"; -import { Pressable, RefreshControl, ScrollView, Text, View } from "react-native"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { SymbolView } from "../../components/ui/AppSymbol"; diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 73629f02fd253..2d0de616bc698 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -5,7 +5,8 @@ import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects"; import { maskMarkdownFrontmatter } from "@t3tools/shared/markdownFrontmatter"; import { useState } from "react"; import { StackActions, useNavigation } from "@react-navigation/native"; -import { RefreshControl, ScrollView, View } from "react-native"; +import { ScrollView, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { FONT_FAMILIES } from "../../lib/fontFamilies"; diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index 279269b8a13c3..24d82a93daa79 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -5,7 +5,8 @@ import type { ProjectEntry } from "@t3tools/contracts"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { SymbolView } from "../../components/ui/AppSymbol"; import { useEffect, useRef, useState } from "react"; -import { Pressable, RefreshControl, View } from "react-native"; +import { Pressable, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { AppText as Text } from "../../components/ui/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 36da917e83d5c..6c796848b6c2d 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -7,13 +7,13 @@ import { useLayoutEffect, useRef, useState } from "react"; import { Platform, Pressable, - RefreshControl, ScrollView, Text as NativeText, useWindowDimensions, View, } from "react-native"; import { NativeScrollView } from "../../native/NativeScrollViewMarker"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { WebView } from "react-native-webview"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 11fa0a0c7af46..6de31137fb7d4 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -226,7 +226,7 @@ function FileSourceSurface(props: ComponentProps) { setSelection({ line, active: true }) : undefined } @@ -250,6 +250,7 @@ function FileContent(props: { readonly threadId: ThreadId | null; readonly initialLine: number | null; readonly truncated: boolean; + readonly selectable: boolean; readonly onRefresh?: () => Promise | void; }) { // Reopening a mutable host file must not reuse a poster from an earlier visit. @@ -351,6 +352,7 @@ function FileContent(props: { path={props.relativePath} initialLine={props.initialLine} headerInsetTop={props.truncated ? 0 : undefined} + selectable={props.selectable} onRefresh={props.onRefresh} /> )} @@ -650,6 +652,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { readonly path: string; readonly mode: FileViewMode; } | null>(null); + const [textSelectionPath, setTextSelectionPath] = useState(null); const [previewRevision, setPreviewRevision] = useState(0); const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]); const [fullScreenPreview, setFullScreenPreview] = useState(null); @@ -749,6 +752,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { : null, ); const fileData = fileQuery.data as ProjectReadFileResult | null; + // iOS only: Android lays out a whole-file selectable TextView on the UI thread, which stalls. + const canSelectText = + Platform.OS === "ios" && resolvedActiveMode === "source" && fileData?.contents != null; + const selectingText = canSelectText && textSelectionPath === relativePath; const handleSelectFile = (path: string) => { const targetPath = @@ -837,6 +844,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => setCodeWordBreak(!appearance.codeWordBreak), } as const) : null, + canSelectText + ? ({ + id: "select-text", + title: selectingText ? "Done selecting" : "Select text", + icon: "character.cursor.ibeam", + inline: false, + onPress: () => setTextSelectionPath(selectingText ? null : relativePath), + } as const) + : null, ...(mediaSource ? mediaActions.actions .filter(({ id }) => id !== "open-file") @@ -1046,6 +1062,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { relativePath={relativePath} threadId={threadId} truncated={fileData?.truncated ?? false} + selectable={selectingText} onRefresh={() => fileQuery.refresh()} /> diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index 46b26d7e3bf3d..c58316af3698b 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import { fileRoutePathSegments, + isAudioPreviewFile, + isMarkdownPreviewFile, needsWorkspaceFileContents, resolveFileLinkPath, resolveWorkspaceRelativeFilePath, @@ -58,3 +60,14 @@ describe("needsWorkspaceFileContents", () => { expect(needsWorkspaceFileContents({ ...input, metadataKind: "directory" })).toBe(false); }); }); + +describe("workspace preview classifiers", () => { + it("treat # and ? as literal filename characters", () => { + for (const path of ["notes/clip#1.wav", "notes/clip?1.wav", "notes#old/clip.wav"]) { + expect(isAudioPreviewFile(path)).toBe(true); + } + for (const path of ["docs/draft#1.md", "docs/draft?1.md", "docs#old/draft.md"]) { + expect(isMarkdownPreviewFile(path)).toBe(true); + } + }); +}); diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index e38ff86fa6656..330ff5784da6d 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -100,15 +100,15 @@ export function isVideoPreviewFile(path: string): boolean { } export function isAudioPreviewFile(path: string): boolean { - return isWorkspaceAudioPreviewPath(path.split(/[?#]/, 1)[0] ?? ""); + return isWorkspaceAudioPreviewPath(path); } export function isSvgImagePreviewFile(path: string): boolean { - return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? ""); + return /\.svg$/i.test(path); } export function isMarkdownPreviewFile(path: string): boolean { - return /\.(?:md|mdx)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); + return /\.(?:md|mdx)$/i.test(path); } export function needsWorkspaceFileContents(input: { diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx new file mode 100644 index 0000000000000..21f64998a3be9 --- /dev/null +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx @@ -0,0 +1,79 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const queries = vi.hoisted(() => [] as { cwd: string | null; searchQuery: string }[]); + +vi.mock("@t3tools/client-runtime/state/filesystem", () => ({ + resolveFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false }), +})); +vi.mock("../../state/session", () => ({ environmentSession: { sessionStateAtom: () => null } })); +vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null, error: null }) })); +vi.mock("../../state/presentation", () => ({ + useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), +})); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ themeAppearance: "dark" }), +})); +vi.mock("./preload-workspace-file", () => ({ preloadWorkspaceFileContents: () => {} })); +vi.mock("./FileTreeBrowser", () => ({ FileTreeBrowser: () => null })); +vi.mock("./useFileTreeEntries", () => ({ + useFileTreeEntries: (input: { cwd: string | null; searchQuery: string }) => { + queries.push({ cwd: input.cwd, searchQuery: input.searchQuery }); + return { refresh: () => {}, selectPath: () => {} }; + }, +})); +vi.mock("../../components/ui/SheetPane", () => ({ + SheetPane: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("../../components/ui/ScreenContent", () => ({ + ScreenContent: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("../../components/ui/AppBar", () => ({ + AppBar: (props: { search: { value: string; onChangeText: (value: string) => void } }) => ( + props.search.onChangeText(event.target.value)} + /> + ), +})); + +import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; + +it("starts unfiltered when the mounted pane switches to another workspace", () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + const render = (cwd: string) => + act(() => + root.render( + {}} + />, + ), + ); + try { + render("/first"); + const input = container.querySelector("input")!; + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( + input, + "alpha", + ); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(queries.at(-1)).toEqual({ cwd: "/first", searchQuery: "alpha" }); + + render("/second"); + expect(queries.at(-1)).toEqual({ cwd: "/second", searchQuery: "" }); + expect(container.querySelector("input")!.value).toBe(""); + } finally { + act(() => root.unmount()); + container.remove(); + } +}); 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 dd95959b30899..cc0c86e6d3e4b 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -4,7 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { AppBar } from "../../components/ui/AppBar"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { useState } from "react"; -import { View } from "react-native"; +import { SheetPane } from "../../components/ui/SheetPane"; import { useEnvironmentQuery } from "../../state/query"; import { useEnvironmentPresentation } from "../../state/presentation"; @@ -20,7 +20,11 @@ export function ThreadFileNavigatorPane(props: { readonly selectedPath: string | null; readonly onSelectFile: (path: string) => void; }) { - const [searchQuery, setSearchQuery] = useState(""); + const workspaceKey = JSON.stringify([props.environmentId, props.cwd]); + // The pane stays mounted across thread switches; a query only filters the workspace it was typed in. + const [search, setSearch] = useState({ workspaceKey, query: "" }); + const searchQuery = search.workspaceKey === workspaceKey ? search.query : ""; + const setSearchQuery = (query: string) => setSearch({ workspaceKey, query }); const { themeAppearance: highlightTheme } = useAppearancePreferences(); const fileAccessSession = useEnvironmentQuery( environmentSession.sessionStateAtom(props.environmentId), @@ -48,7 +52,7 @@ export function ThreadFileNavigatorPane(props: { }; const fileTree = ( - {fileTree} + - + {fileTree} + ); } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 708cb568c1c2c..b3339a0188182 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -18,7 +18,7 @@ import { useProjects } from "../../state/entities"; import { useThreadListPages } from "../threads/use-thread-list-pages"; import { useThreadListOrganisations } from "./use-thread-list-organisations"; import { useThreadListFilterFacets } from "./use-thread-list-filter-facets"; -import { environmentThreadShells } from "../../state/threads"; +import { steadyNavigationThreadShellsAtom } from "../../state/threads"; import { useLaunchingThreads, usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -29,7 +29,11 @@ import { import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { useHomeListOptions } from "./home-list-options"; -import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; +import { + runWhenHomeCovered, + useAtomValueWhileVisible, + useHomeRouteVisible, +} from "./home-route-visibility"; import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; @@ -52,7 +56,7 @@ export function HomeRouteScreen() { // Stop rebuilding the streaming thread list while Thread covers Home. const visible = useHomeRouteVisible(); const threads = useLaunchingThreads( - useAtomValueWhileVisible(environmentThreadShells.navigationThreadShellsAtom, visible), + useAtomValueWhileVisible(steadyNavigationThreadShellsAtom, visible), ); const { hiddenTabThreads, groupNames: tabGroupNames } = useHiddenTabThreads(threads); const visibleThreads = (() => @@ -77,13 +81,16 @@ export function HomeRouteScreen() { else if (workspace) workspace.dispatch({ type: "query", query, tab: "threads" }); else setLocalSearchQuery(query); }; - const handleSelectThread = (thread: EnvironmentThreadShell) => - selectDetail({ - name: "Thread", - params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, - }); - const selectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) => + // Split layouts keep the query; only the stacked layout opens the thread over the list. + // Android's search field keeps its own open state, which a cleared query would leave open. + const clearsSearchOnOpen = Platform.OS !== "android" && !layout.usesSplitView; + const selectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) => { + if (searchQuery !== "" && clearsSearchOnOpen && workspace) + runWhenHomeCovered(() => workspace.cancelSearch("threads")); selectDetail({ name: "Thread", params }); + }; + const handleSelectThread = (thread: EnvironmentThreadShell) => + selectThreadRoute({ environmentId: String(thread.environmentId), threadId: String(thread.id) }); const handleNewThreadOnBranch = (thread: EnvironmentThreadShell) => { selectThreadRoute({ draft: { @@ -316,6 +323,7 @@ export function HomeRouteScreen() { groups={groups} onEditGroup={handleEditGroup} threads={visibleThreads} + visible={visible} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index eea201a7a6ee5..a076296041aa0 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -21,7 +21,15 @@ import { type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { use, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; +import { + use, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; import { Platform, ScrollView, @@ -103,10 +111,14 @@ import { import { createSwipeRowActivation } from "./swipe-row-activation"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; import { useFabScroll } from "./FabScrollContext"; +import { holdLaunchSplashForHomeRows, settleLaunchSplashHomeRows } from "./home-launch-splash"; import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; import { ThreadListFilters } from "./ThreadListFilters"; +import { deriveHomeEmptyState } from "./home-empty-state"; interface HomeScreenProps { + /** Whether this list is the one on screen; see `useHomeRouteVisible`. */ + readonly visible: boolean; readonly projects: ReadonlyArray; readonly threads: ReadonlyArray; readonly pendingTasks: ReadonlyArray; @@ -190,73 +202,6 @@ const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; // rows that are already built instead of rows still being rebuilt. const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; -function deriveEmptyState(props: { - readonly catalogState: WorkspaceState; - readonly projectCount: number; -}): { readonly title: string; readonly detail: string; readonly loading: boolean } { - const { catalogState } = props; - if (catalogState.isLoadingConnections) { - return { - title: "Loading environments", - detail: "Checking saved environments on this device.", - loading: true, - }; - } - - if (!catalogState.hasConnections) { - return { - title: "No environments connected", - detail: "Add an environment to load projects and start coding sessions.", - loading: false, - }; - } - - if ( - (catalogState.connectionState === "available" || - catalogState.connectionState === "offline" || - catalogState.connectionState === "error" || - catalogState.connectionState === "unsupported") && - !catalogState.hasLoadedShellSnapshot - ) { - return { - title: - catalogState.connectionState === "unsupported" - ? "Client not supported" - : "Environment unavailable", - detail: - catalogState.connectionError ?? - "The saved environment is offline. Check the URL or start the environment, then retry.", - loading: false, - }; - } - - if ( - catalogState.hasConnectingEnvironment && - !catalogState.hasLoadedShellSnapshot && - catalogState.connectionError === null - ) { - return { - title: "Connecting to environment", - detail: "Loading projects and threads from the saved environment.", - loading: true, - }; - } - - if (props.projectCount === 0 && catalogState.hasLoadedShellSnapshot) { - return { - title: "No projects found", - detail: "The connected environment did not report any projects.", - loading: false, - }; - } - - return { - title: "No threads yet", - detail: "Create a task to start a new coding runtime in one of your connected projects.", - loading: false, - }; -} - function HomeTopContentSpacer() { return ; } @@ -768,15 +713,18 @@ export function HomeScreen(props: HomeScreenProps) { props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0 || threadListV2Layout.settledCount > 0; + // A hidden list gets no layout, so it never reports its rows as laid out. + const hasRowsToLayOut = props.visible && hasAnyThreads && threadListV2Items.length > 0; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds the native launch splash, which is outside React, until the rows are gone or this list unmounts; a layout effect runs before the splash hides in this commit + useLayoutEffect(() => { + return hasRowsToLayOut ? holdLaunchSplashForHomeRows() : undefined; + }, [hasRowsToLayOut]); const selectedEnvironmentLabel = props.selectedEnvironmentId === null ? null : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? "this environment"); - const emptyState = deriveEmptyState({ - catalogState: props.catalogState, - projectCount: props.projects.length, - }); + const emptyState = deriveHomeEmptyState(props.catalogState, props.projects.length); if (!hasAnyThreads) { return ( @@ -799,14 +747,10 @@ export function HomeScreen(props: HomeScreenProps) { activateVisibleRows(threadListV2Items)} + onLoad={() => { + settleLaunchSplashHomeRows(); + activateVisibleRows(threadListV2Items); + }} onTouchStart={(event) => trackListTouches(event, true)} onTouchEnd={(event) => trackListTouches(event, false)} onTouchCancel={(event) => trackListTouches(event, false)} diff --git a/apps/mobile/src/features/home/home-empty-state.test.ts b/apps/mobile/src/features/home/home-empty-state.test.ts new file mode 100644 index 0000000000000..c7703a8655339 --- /dev/null +++ b/apps/mobile/src/features/home/home-empty-state.test.ts @@ -0,0 +1,104 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; +import { deriveHomeEmptyState } from "./home-empty-state"; + +function connectingEnvironment( + connectionState: "connecting" | "reconnecting", + connectionError: string | null, +): WorkspaceEnvironment { + return { + environmentId: EnvironmentId.make("environment-1"), + environmentLabel: "corex", + displayUrl: "", + isRelayManaged: false, + isEnabled: true, + connectionState, + connectionError, + connectionErrorTraceId: null, + }; +} + +function workspaceState(overrides: Partial = {}): WorkspaceState { + return { + isLoadingConnections: false, + hasConnections: true, + hasLoadedShellSnapshot: true, + hasPendingShellSnapshot: false, + hasSyncingConnectedEnvironment: false, + hasReadyEnvironment: true, + hasConnectingEnvironment: false, + connectingEnvironments: [], + connectionState: "connected", + connectionError: null, + shellSnapshotError: null, + networkStatus: "online", + ...overrides, + }; +} + +const RETAINED_FAILURE = "Environment did not respond before the connection timeout."; +const reconnecting = connectingEnvironment("reconnecting", RETAINED_FAILURE); + +describe("home empty state", () => { + it.each([ + [ + "waits while a saved environment retries after a retained failure", + workspaceState({ + hasLoadedShellSnapshot: false, + hasPendingShellSnapshot: true, + hasReadyEnvironment: false, + hasConnectingEnvironment: true, + connectingEnvironments: [reconnecting], + connectionState: "reconnecting", + connectionError: RETAINED_FAILURE, + }), + 1, + { title: "Connecting to environment", loading: true, showsAddEnvironment: false }, + ], + [ + "waits for a connected environment's first shell snapshot", + workspaceState({ + hasLoadedShellSnapshot: false, + hasPendingShellSnapshot: true, + hasSyncingConnectedEnvironment: true, + }), + 0, + { title: "Loading threads", loading: true, showsAddEnvironment: false }, + ], + [ + "waits while another environment is still connecting after one loaded empty", + workspaceState({ + hasPendingShellSnapshot: true, + hasConnectingEnvironment: true, + connectingEnvironments: [reconnecting], + connectionError: RETAINED_FAILURE, + }), + 0, + { title: "Loading threads", loading: true, showsAddEnvironment: false }, + ], + [ + "reports no threads once every environment has resolved", + workspaceState(), + 1, + { title: "No threads yet", loading: false, showsAddEnvironment: false }, + ], + [ + "does not wait on retries while the device is offline", + workspaceState({ + hasPendingShellSnapshot: true, + hasReadyEnvironment: false, + hasConnectingEnvironment: true, + connectingEnvironments: [reconnecting], + connectionState: "offline", + networkStatus: "offline", + }), + 1, + { title: "No threads yet", loading: false, showsAddEnvironment: true }, + ], + ] as const)("%s", (_name, state, projectCount, expected) => { + const { title, loading, showsAddEnvironment } = deriveHomeEmptyState(state, projectCount); + expect({ title, loading, showsAddEnvironment }).toEqual(expected); + }); +}); diff --git a/apps/mobile/src/features/home/home-empty-state.ts b/apps/mobile/src/features/home/home-empty-state.ts new file mode 100644 index 0000000000000..66f231a546840 --- /dev/null +++ b/apps/mobile/src/features/home/home-empty-state.ts @@ -0,0 +1,81 @@ +import { isWorkspaceCatalogPending, type WorkspaceState } from "../../state/workspaceModel"; + +export interface HomeEmptyState { + readonly title: string; + readonly detail: string; + readonly loading: boolean; + readonly showsAddEnvironment: boolean; +} + +export function deriveHomeEmptyState( + catalogState: WorkspaceState, + projectCount: number, +): HomeEmptyState { + const emptyState = deriveEmptyState(catalogState, projectCount); + return { + ...emptyState, + showsAddEnvironment: !emptyState.loading && !catalogState.hasReadyEnvironment, + }; +} + +function deriveEmptyState( + catalogState: WorkspaceState, + projectCount: number, +): Omit { + if (catalogState.isLoadingConnections) { + return { + title: "Loading environments", + detail: "Checking saved environments on this device.", + loading: true, + }; + } + + if (!catalogState.hasConnections) { + return { + title: "No environments connected", + detail: "Add an environment to load projects and start coding sessions.", + loading: false, + }; + } + + if ( + (catalogState.connectionState === "available" || + catalogState.connectionState === "offline" || + catalogState.connectionState === "error" || + catalogState.connectionState === "unsupported") && + !catalogState.hasLoadedShellSnapshot + ) { + return { + title: + catalogState.connectionState === "unsupported" + ? "Client not supported" + : "Environment unavailable", + detail: + catalogState.connectionError ?? + "The saved environment is offline. Check the URL or start the environment, then retry.", + loading: false, + }; + } + + if (isWorkspaceCatalogPending(catalogState)) { + return { + title: catalogState.hasReadyEnvironment ? "Loading threads" : "Connecting to environment", + detail: "Loading projects and threads from the saved environment.", + loading: true, + }; + } + + if (projectCount === 0 && catalogState.hasLoadedShellSnapshot) { + return { + title: "No projects found", + detail: "The connected environment did not report any projects.", + loading: false, + }; + } + + return { + title: "No threads yet", + detail: "Create a task to start a new coding runtime in one of your connected projects.", + loading: false, + }; +} diff --git a/apps/mobile/src/features/home/home-launch-splash.test.ts b/apps/mobile/src/features/home/home-launch-splash.test.ts new file mode 100644 index 0000000000000..d571811b33d70 --- /dev/null +++ b/apps/mobile/src/features/home/home-launch-splash.test.ts @@ -0,0 +1,70 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { createLaunchSplashHomeRows, splashCanHide } from "./home-launch-splash"; + +const launched = { + preferencesReady: true, + shellCachesRead: true, + homeRowsPending: false, + waitEnded: false, +}; + +it.each([ + { + name: "hides when the cached shells are read and no rows wait for layout", + input: launched, + hides: true, + }, + { + name: "waits while Home still lays out the cached rows", + input: { ...launched, homeRowsPending: true }, + hides: false, + }, + { + name: "waits for the cached shells when Home has no rows yet", + input: { ...launched, shellCachesRead: false }, + hides: false, + }, + { + name: "stops waiting for rows at the limit", + input: { ...launched, homeRowsPending: true, waitEnded: true }, + hides: true, + }, + { + name: "stops waiting for a cache read that fails at the limit", + input: { ...launched, shellCachesRead: false, homeRowsPending: true, waitEnded: true }, + hides: true, + }, + { + name: "waits for preferences even after the limit", + input: { ...launched, preferencesReady: false, waitEnded: true }, + hides: false, + }, +])("the launch splash $name", ({ input, hides }) => { + expect(splashCanHide(input)).toBe(hides); +}); + +it("the launch splash is held only while a Home list has rows to lay out", () => { + const rows = createLaunchSplashHomeRows(); + const onChange = vi.fn(); + rows.subscribe(onChange); + + const release = rows.hold(); + expect(rows.pending()).toBe(true); + // The server answered with no rows, or the list unmounted, before its first layout. + release(); + expect(rows.pending(), "a list that lost its rows releases the splash").toBe(false); + expect(onChange).toHaveBeenCalledTimes(2); + + const releaseLater = rows.hold(); + const releaseOther = rows.hold(); + releaseLater(); + release(); + expect(rows.pending(), "another list still has rows to lay out").toBe(true); + + rows.settle(); + expect(rows.pending()).toBe(false); + releaseOther(); + rows.hold(); + expect(rows.pending(), "a list that mounts after the launch holds nothing").toBe(false); +}); diff --git a/apps/mobile/src/features/home/home-launch-splash.ts b/apps/mobile/src/features/home/home-launch-splash.ts new file mode 100644 index 0000000000000..16069f051d453 --- /dev/null +++ b/apps/mobile/src/features/home/home-launch-splash.ts @@ -0,0 +1,75 @@ +import { useSyncExternalStore } from "react"; + +/** + * Whether the launch splash still waits for Home's list. A list holds the splash while it + * has cached rows to lay out, so the first frame after the splash shows them. + */ +export function createLaunchSplashHomeRows() { + let holds = 0; + let settled = false; + const listeners = new Set<() => void>(); + const pending = () => !settled && holds > 0; + const change = (apply: () => void) => { + const before = pending(); + apply(); + if (pending() !== before) for (const listener of listeners) listener(); + }; + return { + pending, + /** + * A Home list has rows that are not laid out yet. Call the returned release when it no + * longer has them or unmounts; rows that come later can hold again. + */ + hold: () => { + let held = true; + change(() => { + holds += 1; + }); + return () => { + if (!held) return; + held = false; + change(() => { + holds -= 1; + }); + }; + }, + /** + * The rows are laid out, the launch opened another route, or the splash is gone. It is + * final: lists that mount after the launch must not hold anything. + */ + settle: () => + change(() => { + settled = true; + }), + subscribe: (onChange: () => void) => { + listeners.add(onChange); + return () => { + listeners.delete(onChange); + }; + }, + }; +} + +const homeRows = createLaunchSplashHomeRows(); + +export const holdLaunchSplashForHomeRows = homeRows.hold; +export const settleLaunchSplashHomeRows = homeRows.settle; +export const launchSplashHomeRowsPending = homeRows.pending; + +export function useLaunchSplashHomeRowsPending(): boolean { + return useSyncExternalStore(homeRows.subscribe, homeRows.pending); +} + +/** + * Whether the native splash can hide: after the cached shells are read and Home's list has + * laid out their rows. One limit ends both waits. + */ +export function splashCanHide(input: { + readonly preferencesReady: boolean; + readonly shellCachesRead: boolean; + readonly homeRowsPending: boolean; + readonly waitEnded: boolean; +}): boolean { + if (!input.preferencesReady) return false; + return input.waitEnded || (input.shellCachesRead && !input.homeRowsPending); +} diff --git a/apps/mobile/src/features/home/home-route-visibility.test.ts b/apps/mobile/src/features/home/home-route-visibility.test.ts new file mode 100644 index 0000000000000..2ab23be17a71c --- /dev/null +++ b/apps/mobile/src/features/home/home-route-visibility.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + recordHomeTransitionEnd, + recordHomeTransitionStart, + runWhenHomeCovered, +} from "./home-route-visibility"; + +vi.mock("@react-navigation/native", () => ({ useNavigation: vi.fn() })); + +describe("runWhenHomeCovered", () => { + it("waits for the page opening over Home to finish covering it, then runs once", () => { + const clearSearch = vi.fn(); + runWhenHomeCovered(clearSearch); + recordHomeTransitionStart(true); + expect(clearSearch).not.toHaveBeenCalled(); + recordHomeTransitionEnd(true, true); + expect(clearSearch).not.toHaveBeenCalled(); + recordHomeTransitionEnd(true, false); + expect(clearSearch).toHaveBeenCalledTimes(1); + recordHomeTransitionStart(false); + recordHomeTransitionEnd(true, false); + expect(clearSearch).toHaveBeenCalledTimes(1); + }); + + it("drops the action when Home reappears before it is covered", () => { + const clearSearch = vi.fn(); + runWhenHomeCovered(clearSearch); + recordHomeTransitionStart(true); + recordHomeTransitionStart(false); + recordHomeTransitionEnd(false, false); + recordHomeTransitionEnd(true, false); + expect(clearSearch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/home/home-route-visibility.test.tsx b/apps/mobile/src/features/home/home-route-visibility.test.tsx index b720bbae033ac..dae399420944a 100644 --- a/apps/mobile/src/features/home/home-route-visibility.test.tsx +++ b/apps/mobile/src/features/home/home-route-visibility.test.tsx @@ -4,7 +4,10 @@ import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; -import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; +import { + NativePrimaryColumnContext, + NativeWorkspaceTabActivityContext, +} from "../../native/v5-workspace-context"; // The Thread in the detail column is the focused route on iPad split view. vi.mock("@react-navigation/native", () => ({ @@ -12,6 +15,7 @@ vi.mock("@react-navigation/native", () => ({ })); import { + recordHomeFocus, recordHomeTransitionEnd, recordHomeTransitionStart, useAtomValueWhileVisible, @@ -54,6 +58,18 @@ function setup() { ), ); }, + /** A phone has no primary column: Home is the root page of its tab's slot. */ + showHomeInTabSlot(active: boolean) { + act(() => + root.render( + + active }}> + + + , + ), + ); + }, [Symbol.dispose]() { act(() => root.unmount()); registry.dispose(); @@ -71,11 +87,51 @@ it("keeps the sidebar thread list live after switching tabs beside a focused Thr // Chats → PRs → Chats: Chats' column will-appear, then the PRs column's did-disappear. act(() => { - recordHomeTransitionEnd(true); + recordHomeTransitionEnd(true, false); recordHomeTransitionStart(false); - recordHomeTransitionEnd(true); + recordHomeTransitionEnd(true, false); }); act(() => home.registry.set(shellsAtom, 2)); expect(home.latest, "new thread shells reach the sidebar list").toBe(2); }); + +// Each phone tab slot shows its own copy of the Home route. PRs keeps Home focused; Settings +// is a route of its own. +it.each([ + { tab: "PRs", homeFocusedUnderTab: true }, + { tab: "Settings", homeFocusedUnderTab: false }, +])( + "keeps the phone thread list live after a round trip through $tab", + ({ homeFocusedUnderTab }) => { + using home = setup(); + recordHomeFocus(); + home.showHomeInTabSlot(true); + act(() => home.registry.set(shellsAtom, 1)); + expect(home.latest).toBe(1); + + // To the other tab: its slot will-appear, then the Chats slot's did-disappear. + act(() => { + recordHomeTransitionStart(false); + recordHomeTransitionEnd(true, homeFocusedUnderTab); + }); + home.showHomeInTabSlot(false); + act(() => home.registry.set(shellsAtom, 2)); + expect(home.latest, "the list under another tab holds its rows").toBe(1); + + // Back to Chats: its slot will-appear, then the other slot's did-disappear comes last. + act(() => { + recordHomeFocus(); + recordHomeTransitionStart(false); + recordHomeTransitionEnd(true, true); + }); + home.showHomeInTabSlot(true); + act(() => home.registry.set(shellsAtom, 3)); + expect(home.latest, "a server rename reaches the list").toBe(3); + + // A pushed Thread takes the focus and still stops the list. + act(() => recordHomeTransitionEnd(true, false)); + act(() => home.registry.set(shellsAtom, 4)); + expect(home.latest, "the list under a Thread holds its rows").toBe(3); + }, +); diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts index 532053f8f8d07..51bd29234759e 100644 --- a/apps/mobile/src/features/home/home-route-visibility.ts +++ b/apps/mobile/src/features/home/home-route-visibility.ts @@ -3,7 +3,10 @@ import { useNavigation } from "@react-navigation/native"; import { useContext, useRef, useSyncExternalStore } from "react"; import type { Atom } from "effect/reactivity"; -import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; +import { + NativePrimaryColumnContext, + NativeWorkspaceTabActivityContext, +} from "../../native/v5-workspace-context"; /** * Home stays mounted under a pushed Thread, and native-stack never freezes the @@ -30,12 +33,29 @@ function subscribeHomeVisibility(onChange: () => void) { }; } +let onHomeCovered: (() => void) | null = null; + +/** Runs `action` once the page opening over Home covers it; Home starting to reappear first drops it. */ +export function runWhenHomeCovered(action: () => void): void { + onHomeCovered = action; +} + export function recordHomeTransitionStart(closing: boolean): void { - if (!closing) setHomeVisible(true); + if (closing) return; + onHomeCovered = null; + setHomeVisible(true); } -export function recordHomeTransitionEnd(closing: boolean): void { - if (closing) setHomeVisible(false); +/** + * Each iOS tab slot shows its own copy of the Home route, and the slot a tab switch leaves + * disappears last. So a disappear hides Home only when another route has the focus. + */ +export function recordHomeTransitionEnd(closing: boolean, homeFocused: boolean): void { + if (!closing || homeFocused) return; + setHomeVisible(false); + const action = onHomeCovered; + onHomeCovered = null; + action?.(); } /** Popping a sheet or a deep-linked route above Home has no appear event. */ @@ -46,10 +66,13 @@ export function recordHomeFocus(): void { export function useHomeRouteVisible(): boolean { const navigation = useNavigation(); const primaryColumn = useContext(NativePrimaryColumnContext); + const tabActive = useContext(NativeWorkspaceTabActivityContext)?.active ?? true; const visible = useSyncExternalStore(subscribeHomeVisibility, () => homeVisible); // Every tab slot's primary column reports to the one Home route, so a tab // switch can end on the leaving slot's disappear. Trust this column instead. if (primaryColumn) return primaryColumn.presented; + // The store is true for the Home route of every tab; this list shows in one of them. + if (!tabActive) return false; // Before Home's first navigation event: a cold deep link mounts Home under // its Thread without ever showing it. return visible ?? navigation.isFocused(); diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index b2c44e740fbd2..126128f30bd92 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -7,6 +7,7 @@ import { ProviderInstanceId, RunId, ThreadId, + type ThreadTitleRegenerationOutcome, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; @@ -16,7 +17,9 @@ const state = vi.hoisted(() => ({ connectedEnvironmentIds: [] as EnvironmentId[], pendingOrder: null as object | null, dropBusy: false, + holdingRows: false, failedAction: null as string | null, + titleOutcome: null as ThreadTitleRegenerationOutcome | null, archivedRefreshes: [] as string[], scopes: new Map>(), shells: [] as EnvironmentThreadShell[], @@ -84,26 +87,28 @@ vi.mock("../../state/atom-registry", () => ({ ? state.dropBusy : atom === "project:web" ? { id: "web", title: "Web", workspaceRoot: "/repos/web" } - : atom === "thread-shells" - ? state.shells - : atom === "queued-thread-keys" - ? new Set() - : new Map( - [...state.scopes.keys()].map((environmentId) => [ - environmentId, - { - environment: { - capabilities: { - threadSettlement: true, - threadSnooze: true, - threadPinning: true, - threadPinReorder: true, - threadTitleRegeneration: true, + : atom === "steady-shells-holding" + ? state.holdingRows + : atom === "thread-shells" + ? state.shells + : atom === "queued-thread-keys" + ? new Set() + : new Map( + [...state.scopes.keys()].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { + threadSettlement: true, + threadSnooze: true, + threadPinning: true, + threadPinReorder: true, + threadTitleRegeneration: true, + }, }, }, - }, - ]), - ), + ]), + ), }, })); vi.mock("../../state/projects", () => ({ @@ -132,10 +137,9 @@ vi.mock("../../state/thread-order", () => ({ }, })); vi.mock("../../state/threads", () => ({ - environmentThreadShells: { - threadShellsAtom: "thread-shells", - navigationThreadShellsAtom: "thread-shells", - }, + environmentThreadShells: { threadShellsAtom: "thread-shells" }, + steadyNavigationThreadShellsAtom: "thread-shells", + steadyShellsHoldingAtom: "steady-shells-holding", threadEnvironment: Object.fromEntries( [ "archive", @@ -151,6 +155,7 @@ vi.mock("../../state/threads", () => ({ "reorderActive", "updateMetadata", "moveToProject", + "regenerateTitle", ].map((action) => [ action, async (request: { @@ -165,7 +170,7 @@ vi.mock("../../state/threads", () => ({ if (action === state.failedAction) return AsyncResult.failure(Cause.fail(new Error("Server rejected action"))); state.afterRequest?.(); - return AsyncResult.success(undefined); + return AsyncResult.success(action === "regenerateTitle" ? state.titleOutcome : undefined); }, ]), ), @@ -196,15 +201,17 @@ const mutationCases = [ ["unsnoozeThread", "unsnooze"], ["pinThread", "pin"], ["unpinThread", "unpin"], - ["regenerateThreadTitle", "updateMetadata"], + ["regenerateThreadTitle", "regenerateTitle"], ] as const; beforeEach(() => { state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; state.failedAction = null; + state.titleOutcome = null; state.archivedRefreshes = []; state.pendingOrder = null; state.dropBusy = false; + state.holdingRows = false; state.scopes = new Map([ [primaryEnvironmentId, new Set([AuthOrchestrationOperateScope])], [otherEnvironmentId, new Set()], @@ -223,6 +230,16 @@ afterEach(() => { vi.unstubAllEnvs(); }); +it("reports a failed title generation as unsuccessful and allows another request", async () => { + const actions = useThreadListActions(); + const thread = makeThread(); + state.titleOutcome = { status: "failed", message: "Model unavailable" }; + expect(await actions.regenerateThreadTitle(thread)).toBe(false); + + state.titleOutcome = { status: "renamed" }; + expect(await actions.regenerateThreadTitle(thread)).toBe(true); +}); + describe("thread list operation permissions", () => { it.each(mutationCases)( "%s rejects a retained callback after its environment loses permission", @@ -308,6 +325,12 @@ describe("pinned thread operation permissions", () => { }), ]; + // Rows that wait to show can hold order keys the server has replaced. + state.holdingRows = true; + expect(await useThreadListActions().moveThread(moved, "up")).toBe(false); + expect(state.requests).toEqual([]); + state.holdingRows = false; + expect(await useThreadListActions().moveThread(moved, "up")).toBe(true); expect(state.requests).toEqual([ expect.objectContaining({ action: "reorderPin", environmentId: primaryEnvironmentId }), @@ -528,6 +551,44 @@ describe("thread action undo", () => { }, ); + it.each(["settleThread", "unpinThread"] as const)( + "%s plans its undo from the live row when the list holds an older one", + async (handler) => { + const held = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z", pinOrderKey: "m" }); + const live = makeThread({ + pinnedAt: "2026-09-02T00:00:00.000Z", + pinOrderKey: "t", + snoozedUntil: "2099-01-01T09:00:00.000Z", + }); + state.shells = [live]; + await useThreadListActions()[handler](held); + await readThreadUndoNotice()!.undo(); + expect(state.requests.slice(-2)).toMatchObject([ + { action: "pin", input: { threadId: live.id, orderKey: "t" } }, + { action: "snooze", input: { threadId: live.id, snoozedUntil: live.snoozedUntil } }, + ]); + }, + ); + + it("keeps the live model when changing a held row's subscription limit", async () => { + const held = makeThread(); + const live = makeThread({ modelSelection: { ...held.modelSelection, model: "new-model" } }); + state.shells = [live]; + await useThreadListActions().setThreadSubscriptionLimitGuard(held, true); + expect(state.requests).toMatchObject([ + { + action: "updateMetadata", + input: { + threadId: live.id, + modelSelection: { + ...live.modelSelection, + options: [{ id: "stopAtSubscriptionLimit", value: true }], + }, + }, + }, + ]); + }); + // regression: #21401 restore archive and thread action undo it("does not restore snooze if undoing an unpin fails", async () => { const thread = makeThread({ @@ -604,5 +665,10 @@ describe("thread archive guard", () => { expect(state.requests).toEqual([]); expect(state.archivedRefreshes).toEqual([]); expect(state.alerts.at(-1)?.title).toBe("Could not archive thread"); + + // A list can hold a row from before the run started. + state.shells = [thread]; + expect(await useThreadListActions().archiveThread(makeThread())).toBe(false); + expect(state.requests).toEqual([]); }); }); diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 9e3af7b6d0286..360f8b296fad2 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -19,7 +19,12 @@ import { environmentServerConfigsAtom } from "../../state/server"; import { connectedEnvironmentIdsAtom } from "../../state/presentation"; import { readEnvironmentScope } from "../../state/session"; import { environmentProjects } from "../../state/projects"; -import { environmentThreadShells, threadEnvironment } from "../../state/threads"; +import { + environmentThreadShells, + steadyNavigationThreadShellsAtom, + steadyShellsHoldingAtom, + threadEnvironment, +} from "../../state/threads"; import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; import { useAtomCommand } from "../../state/use-atom-command"; import { @@ -101,6 +106,19 @@ function checkThreadOperationPermission(thread: EnvironmentThreadShell, title: s return false; } +/** + * The live row of a thread that a list passes to an action. A list holds its rows while an + * environment synchronizes (`steadyNavigationThreadShellsAtom`), so its row can be older than + * the client's data: an undo plan or a guard read from it would use replaced state. + */ +function liveThread(thread: EnvironmentThreadShell): EnvironmentThreadShell { + return ( + appAtomRegistry + .get(environmentThreadShells.threadShellsAtom) + .find((row) => row.id === thread.id && row.environmentId === thread.environmentId) ?? thread + ); +} + function useThreadActionExecutor( onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, ) { @@ -270,6 +288,9 @@ export function useThreadListActions(): { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const regenerateTitle = useAtomCommand(threadEnvironment.regenerateTitle, { + reportFailure: false, + }); const snoozeInFlightThreadKeys = useRef(new Set()); const titleRegenerationInFlightThreadKeys = useRef(new Set()); @@ -715,9 +736,9 @@ export function useThreadListActions(): { titleRegenerationInFlightThreadKeys.current.add(key); selectionHaptic(); const succeeded = await (async () => { - const result = await updateThreadMetadata({ + const result = await regenerateTitle({ environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, + input: { threadId: thread.id }, }); if (result._tag === "Failure") { const error = Cause.squash(result.cause); @@ -729,6 +750,13 @@ export function useThreadListActions(): { ); return false; } + if (result.value?.status === "failed") { + Dialog.alert( + "Could not regenerate title", + result.value.message ?? "The thread title could not be regenerated.", + ); + return false; + } return true; })(); titleRegenerationInFlightThreadKeys.current.delete(key); @@ -776,7 +804,10 @@ export function useThreadListActions(): { const moveThread = async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { if (!checkThreadOperationPermission(thread, "Could not move thread")) return false; if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; - const shells = appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom); + // The rows on screen can be older than the server's while they wait to show. A move + // planned from them could act on a pin or an order key that the server has replaced. + if (appAtomRegistry.get(steadyShellsHoldingAtom)) return false; + const shells = appAtomRegistry.get(steadyNavigationThreadShellsAtom); const current = shells.find( (row) => row.id === thread.id && row.environmentId === thread.environmentId, ); @@ -912,17 +943,19 @@ export function useThreadListActions(): { const confirmDeleteThread = (thread: EnvironmentThreadShell) => confirmThreadDeletion(executeAction, thread); + // `moveThread` looks up its own row, and refuses while a list holds older rows. return { - archiveThread, - confirmDeleteThread, - settleThread, - snoozeThread, - unsnoozeThread, - unsettleThread, - pinThread, - unpinThread, - setThreadAutoSettle, - setThreadSubscriptionLimitGuard, + archiveThread: (thread) => archiveThread(liveThread(thread)), + confirmDeleteThread: (thread) => confirmDeleteThread(liveThread(thread)), + settleThread: (thread) => settleThread(liveThread(thread)), + snoozeThread: (thread, snoozedUntil) => snoozeThread(liveThread(thread), snoozedUntil), + unsnoozeThread: (thread) => unsnoozeThread(liveThread(thread)), + unsettleThread: (thread) => unsettleThread(liveThread(thread)), + pinThread: (thread) => pinThread(liveThread(thread)), + unpinThread: (thread) => unpinThread(liveThread(thread)), + setThreadAutoSettle: (thread, enabled) => setThreadAutoSettle(liveThread(thread), enabled), + setThreadSubscriptionLimitGuard: (thread, enabled) => + setThreadSubscriptionLimitGuard(liveThread(thread), enabled), setThreadHidden, setThreadGroup, moveThreadToNewGroup, @@ -930,8 +963,8 @@ export function useThreadListActions(): { createThreadGroup, editThreadGroup, moveThread, - renameThread, - regenerateThreadTitle, + renameThread: (thread) => renameThread(liveThread(thread)), + regenerateThreadTitle: (thread) => regenerateThreadTitle(liveThread(thread)), }; } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 19ffe072fb58e..3a32e33523611 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -112,7 +112,7 @@ import { workspaceTabColumns, workspaceTabNavigationState, } from "./workspace/workspace-tab-navigation"; -import type { WorkspaceTab } from "./workspace/workspace-tabs-state"; +import { workspaceTabOf, type WorkspaceTab } from "./workspace/workspace-tabs-state"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout"; import { WorkspaceThreadSidebar } from "./workspace/WorkspaceThreadSidebar"; @@ -570,10 +570,7 @@ function AdaptiveWorkspaceLayoutContent( setFileInspectorPreferredVisible(false); setInspectorSheetRoute(null); if (layout.variant === "medium") setPrimarySidebarVisible(false); - workspace?.dispatch({ - type: "select", - tab: detail.name === "Thread" ? "threads" : "pullRequests", - }); + workspace?.dispatch({ type: "select", tab: workspaceTabOf(detail.name) }); startOpeningDetail(detail); navigation.dispatch(selectWorkspaceDetail(detail, sheet)); }); diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx index 2ee2000d400f0..d4930f4cdecb0 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx @@ -19,6 +19,7 @@ import { } from "@react-navigation/native-stack"; import { use, type ComponentProps } from "react"; +import { BottomSheetContext } from "../../components/ui/BottomSheetContext"; import { RoutedFormSheetContext } from "../../components/ui/RoutedFormSheetContext"; import { @@ -131,7 +132,11 @@ function WorkspaceStackNavigator({ ...(descriptor.options.presentation === "formSheet" ? { render: () => ( - {descriptor.render()} + + + {descriptor.render()} + + ), } : undefined), diff --git a/apps/mobile/src/features/layout/workspace-inspector-sheet.tsx b/apps/mobile/src/features/layout/workspace-inspector-sheet.tsx index 9fec5e64df699..f2f9c0242563e 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-sheet.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-sheet.tsx @@ -1,10 +1,8 @@ import { createContext, useRef, useState } from "react"; -import { Platform, View } from "react-native"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; -import { FormSheet } from "react-native-screens"; -import { useResolveClassNames } from "uniwind"; import { PaneContext } from "../../components/ui/Pane"; +import { PaneSheet } from "../../components/ui/PaneSheet"; import { RoutedFormSheetContext } from "../../components/ui/RoutedFormSheetContext"; import { formSheetDetents } from "../../native/sheet-surface"; import { InspectorPanes } from "./inspector-host"; @@ -22,7 +20,6 @@ export function WorkspaceInspectorSheet(props: { readonly onClose: () => void; readonly content: InspectorContent | undefined; }) { - const containerStyle = useResolveClassNames(Platform.OS === "android" ? "bg-header" : "bg-sheet"); const insets = useSafeAreaInsets(); // Stays mounted until the sheet has finished closing, so it doesn't empty as it slides away. // Each opening gets a fresh native sheet, even one that starts while the last is closing. @@ -51,15 +48,12 @@ export function WorkspaceInspectorSheet(props: { }; if (!presented.mounted) return null; return ( - { props.onClose(); @@ -71,25 +65,11 @@ export function WorkspaceInspectorSheet(props: { {/* The sheet starts below the status bar. */} - {/* Android FormSheet does not bound flex children. */} - - close()} /> - {Platform.OS === "android" ? ( - - ) : null} - + close()} /> - + ); } diff --git a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx index 5fc9b50097ba2..3612561ede6c6 100644 --- a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx +++ b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx @@ -3,6 +3,7 @@ import { createContext, use, type ReactNode } from "react"; import { Platform } from "react-native"; import { androidWorkspaceSettingsOpen } from "./android-workspace-tabs"; import { WorkspaceTabsProvider } from "./WorkspaceTabsContext"; +import { workspaceTabOf } from "./workspace-tabs-state"; interface AndroidWorkspaceRoot { /** @@ -35,7 +36,10 @@ export function AndroidWorkspaceTabsProvider(props: { settingsIndex < 0 ? null : (props.state.routes[settingsIndex + 1]?.name ?? null); const root = { key, settingsOpen, settingsRoute, settingsPage }; return Platform.OS === "android" ? ( - + // A cold-start link lands on a page with no list selection; start on that page's list. + route.name !== "Home")?.name)} + > {props.children} ) : ( diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx index 9ecb6fa933fa8..81344be55e4d2 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx @@ -26,6 +26,8 @@ export const WorkspaceTabsContext = createContext<{ readonly settingsOpen: boolean; readonly getScrollOffset: (tab: WorkspaceSearchTab) => number; readonly recordScrollOffset: (tab: WorkspaceSearchTab, offset: number) => void; + /** Leaves search and returns the list to where it was when search began. */ + readonly cancelSearch: (tab: WorkspaceSearchTab) => void; } | null>(null); export function useWorkspaceTabs() { @@ -52,8 +54,8 @@ function resolveWorkspaceTabsSelection( // One model per provider. Handlers on frozen screens keep stale closures, so // they read the current selection from the registry at call time. -function createWorkspaceTabsModel() { - const stored = Atom.make(initialWorkspaceTabsState()); +function createWorkspaceTabsModel(selectedTab?: WorkspaceTab) { + const stored = Atom.make(initialWorkspaceTabsState(selectedTab)); const scrollOffsets: Record = { threads: 0, pullRequests: 0, @@ -75,19 +77,25 @@ function createWorkspaceTabsModel() { export function WorkspaceTabsProvider(props: { readonly children: ReactNode; readonly settingsOpen?: boolean; + readonly initialTab?: WorkspaceTab; }) { const registry = use(RegistryContext); - const [model] = useState(createWorkspaceTabsModel); + const [model] = useState(() => createWorkspaceTabsModel(props.initialTab)); const state = useAtomValue(model.resolved); + const dispatch = (action: WorkspaceTabsAction) => + registry.set(model.stored, workspaceTabsReducer(registry.get(model.resolved), action)); const context = { state, - dispatch: (action: WorkspaceTabsAction) => - registry.set(model.stored, workspaceTabsReducer(registry.get(model.resolved), action)), + dispatch, isTabActive: (tab: WorkspaceTab) => registry.get(model.resolved).selectedTab === tab, bottomClearance: 0, settingsOpen: props.settingsOpen ?? false, getScrollOffset: model.getScrollOffset, recordScrollOffset: model.recordScrollOffset, + cancelSearch: (tab: WorkspaceSearchTab) => { + model.recordScrollOffset(tab, registry.get(model.resolved).tabs[tab].searchEntryOffset); + dispatch({ type: "cancel", tab }); + }, }; return {props.children}; } diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx index 5557432ce4946..f07076a8fb141 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx @@ -80,10 +80,7 @@ export function WorkspaceToolbar(props: { searchRef.current?.focus(); return true; }; - const cancelSearch = () => { - workspace.recordScrollOffset(selectedTab, tab.searchEntryOffset); - workspace.dispatch({ type: "cancel", tab: selectedTab }); - }; + const cancelSearch = () => workspace.cancelSearch(selectedTab); const searchFocused = () => tab.searchMode === "resting" ? enterSearch() @@ -96,6 +93,8 @@ export function WorkspaceToolbar(props: { // The sidebar remains visible while its detail owns navigation focus. const searchHostVisible = focused || settingsListOnTop || primaryColumn?.presented === true; const restoreSearch = () => { + // A search cancelled while its screen was covered leaves its text in the native field. + if (tab.searchMode === "resting") searchRef.current?.setText(""); if ( searchHostVisible && searchVisible && diff --git a/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts b/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts index dfc8e13d162c1..0165634f0a31e 100644 --- a/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts +++ b/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts @@ -1,50 +1,29 @@ import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import { useCallback } from "react"; import { BackHandler, Keyboard, Platform } from "react-native"; -import { - resetAndroidWorkspace, - returnToAndroidWorkspaceList, -} from "../../../lib/android-workspace-navigation"; -import { useWorkspaceTabs } from "./WorkspaceTabsContext"; -import type { WorkspaceTab } from "./workspace-tabs-state"; +import { returnToAndroidWorkspaceList } from "../../../lib/android-workspace-navigation"; -function useWorkspaceListNavigation(tab: WorkspaceTab) { +export function useWorkspaceBackNavigation() { const navigation = useNavigation(); - const dispatch = useWorkspaceTabs()?.dispatch; return () => { - if (Platform.OS === "android") { - dispatch?.({ type: "select", tab }); - resetAndroidWorkspace(navigation); - } else if (navigation.canGoBack()) navigation.goBack(); + if (Platform.OS === "android") returnToAndroidWorkspaceList(navigation); + else if (navigation.canGoBack()) navigation.goBack(); else navigation.dispatch(StackActions.replace("Home")); }; } -export function useWorkspaceBackNavigation(tab: WorkspaceTab) { - const navigation = useNavigation(); - const dispatch = useWorkspaceTabs()?.dispatch; - const returnToList = useWorkspaceListNavigation(tab); - return () => { - if (Platform.OS === "android") { - returnToAndroidWorkspaceList(navigation, () => dispatch?.({ type: "select", tab })); - } else returnToList(); - }; -} - -export function useAndroidWorkspaceBack(tab: WorkspaceTab) { - const dispatch = useWorkspaceTabs()?.dispatch; - const goBack = useWorkspaceBackNavigation(tab); +export function useAndroidWorkspaceBack() { + const goBack = useWorkspaceBackNavigation(); useFocusEffect( useCallback(() => { if (Platform.OS !== "android") return; - dispatch?.({ type: "select", tab }); const subscription = BackHandler.addEventListener("hardwareBackPress", () => { if (Keyboard.isVisible()) Keyboard.dismiss(); else goBack(); return true; }); return () => subscription.remove(); - }, [goBack, dispatch, tab]), + }, [goBack]), ); return goBack; } diff --git a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts index 3598894115dc3..3f6754c679c5a 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts @@ -38,9 +38,16 @@ function emptyTab(): WorkspaceTabState { return { query: "", searchMode: "resting", scrollOffset: 0, searchEntryOffset: 0 }; } -export function initialWorkspaceTabsState(): WorkspaceTabsState { +/** The list a workspace page belongs to. */ +export function workspaceTabOf(routeName: string | undefined): WorkspaceTab { + return routeName === "PullRequest" ? "pullRequests" : "threads"; +} + +export function initialWorkspaceTabsState( + selectedTab: WorkspaceTab = "threads", +): WorkspaceTabsState { return { - selectedTab: "threads", + selectedTab, selectionRevision: 0, tabs: { threads: emptyTab(), diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx index ab121d0c41e7d..f495e39cbd264 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx @@ -6,7 +6,7 @@ import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime import type { PullRequestReviewVerdict } from "@t3tools/contracts"; import { useRef, useState } from "react"; import { useFocusEffect } from "@react-navigation/native"; -import { Platform, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import { clearSubmittedReview } from "./pullRequestReviewDrafts"; import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; import { reviewPositionLabel } from "./pullRequestDiffComments"; @@ -158,7 +158,7 @@ export function PullRequestReviewSheet({ : "Post comment" } fullWidth - tone={Platform.OS === "android" ? "primary" : "contrast"} + tone="contrast" loading={submission.busy} disabled={!canSubmit || result.isPending || result.error !== null} onPress={() => void perform()} diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index f682363792032..f3c47e2582858 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -61,7 +61,8 @@ import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading"; import { copyPullRequestLink, sharePullRequestLink } from "./pullRequestLink"; import { pullRequestStatus } from "./pullRequestStatus"; import { use, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { Linking, Platform, Pressable, RefreshControl, StyleSheet, View } from "react-native"; +import { Linking, Platform, Pressable, StyleSheet, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -358,7 +359,7 @@ function usePullRequestToolbarHeight(bottomInset: number) { } export function PullRequestRouteScreen({ route }: StaticScreenProps) { - const returnToList = useAndroidWorkspaceBack("pullRequests"); + const returnToList = useAndroidWorkspaceBack(); const { layout } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); diff --git a/apps/mobile/src/features/pull-requests/PullRequestSearchSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestSearchSheet.tsx index d51b88258c293..691a3ebcc8282 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestSearchSheet.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestSearchSheet.tsx @@ -1,11 +1,10 @@ import { useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; -import { Platform, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { View } from "react-native"; import { AppBar } from "../../components/ui/AppBar"; -import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; -import { NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET } from "../layout/native-mail-search-toolbar"; +import type { AppBarSearch } from "../../components/ui/AppBar.types"; +import { useSearchToolbarInset, useUnadjustedBottomInset } from "../../components/ui/useBarInset"; /** A searchable picker over a pull request list or a pull request. */ export function PullRequestSearchSheet(props: { @@ -19,9 +18,17 @@ export function PullRequestSearchSheet(props: { readonly children: (list: { readonly paddingBottom: number }) => ReactNode; }) { const navigation = useNavigation(); + const search: AppBarSearch = { + mode: "inline", + compactToolbar: true, + value: props.query, + onChangeText: props.onQueryChange, + placeholder: props.placeholder, + clearAccessibilityLabel: props.clearAccessibilityLabel, + }; // The compact search toolbar floats over the end of the list. - const bottomSearch = useNativeMailSearchToolbar(); - const insets = useSafeAreaInsets(); + const searchToolbarInset = useSearchToolbarInset(search); + const bottomInset = useUnadjustedBottomInset(); return ( navigation.goBack()} closeAccessibilityLabel={props.closeAccessibilityLabel} hideBottomBorder - search={{ - mode: "inline", - compactToolbar: true, - value: props.query, - onChangeText: props.onQueryChange, - placeholder: props.placeholder, - clearAccessibilityLabel: props.clearAccessibilityLabel, - }} + search={search} /> {props.children({ // Android's bottom inset also covers the floating workspace tabs while Settings is open. - paddingBottom: - Platform.OS === "android" - ? insets.bottom + 16 - : bottomSearch - ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16 - : 16, + paddingBottom: bottomInset + searchToolbarInset + 16, })} ); diff --git a/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx b/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx index ec3352b40ea3e..f60a13bd9dea6 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx @@ -1,10 +1,8 @@ import { SheetHeader } from "../../components/ui/SheetHeader"; import { useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; -import { Platform, ScrollView, useWindowDimensions, View } from "react-native"; -import { KeyboardAvoidingView } from "react-native-keyboard-controller"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { ScreenContent } from "../../components/ui/ScreenContent"; +import { View } from "react-native"; +import { FitSheet } from "../../components/ui/FitSheet"; export function PullRequestSheetContent(props: { readonly title: string; @@ -14,49 +12,24 @@ export function PullRequestSheetContent(props: { readonly footer?: ReactNode; }) { const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const { height: windowHeight } = useWindowDimensions(); - const android = Platform.OS === "android"; - const footer = props.footer ? ( - - {android ? {props.footer} : props.footer} - - ) : null; return ( - // Material sheet drags follow the first nested-scrolling native view, and the inline - // header's Compose button is one, so Android renders content first and reverses the column. - navigation.goBack()} + dismissDisabled={props.busy} + /> + } + footer={props.footer} + keyboardShouldPersistTaps="handled" + contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 20 }} > - - - - {props.children} - {/* Android partial-height sheets keep submit actions within the scrollable form. */} - {android ? footer : null} - - - {android ? null : footer} - - navigation.goBack()} - dismissDisabled={props.busy} - /> - + + {props.children} + + ); } diff --git a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx index 0b2637b7c6b61..6e69e267e3fef 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx @@ -1,6 +1,6 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; import { useState } from "react"; import { View } from "react-native"; -import { useOpenWorkspaceDetail } from "../layout/useOpenWorkspaceDetail"; import { AppText as Text } from "../../components/ui/AppText"; import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; import { pullRequestLayerParams } from "./pullRequestStack"; @@ -14,7 +14,7 @@ export function PullRequestStackCard(props: { readonly params: PullRequestParams; readonly state: PullRequestStackState; }) { - const openDetail = useOpenWorkspaceDetail(); + const navigation = useNavigation(); const [expanded, setExpanded] = useState(false); const stack = props.state.stack; if (!stack) { @@ -66,12 +66,14 @@ export function PullRequestStackCard(props: { selected ? undefined : () => - openDetail({ - name: "PullRequest", - params: withKnownPullRequestSummary( - pullRequestLayerParams(props.params, layer.number), + navigation.dispatch( + StackActions.push( + "PullRequest", + withKnownPullRequestSummary( + pullRequestLayerParams(props.params, layer.number), + ), ), - }) + ) } last={index === stack.layers.length - 1} /> diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index ec2f9c1a87dc0..f1b58cc8cf484 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -9,7 +9,8 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; import { useNavigation } from "@react-navigation/native"; import { use, useMemo, useRef, useState, type ReactNode } from "react"; import { useAtomValue } from "@effect/atom-react"; -import { Keyboard, Platform, Pressable, RefreshControl, View } from "react-native"; +import { Keyboard, Platform, Pressable, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { useSafeAreaInsets } from "react-native-safe-area-context"; diff --git a/apps/mobile/src/features/pull-requests/pullRequestActions.ts b/apps/mobile/src/features/pull-requests/pullRequestActions.ts index e6c04f192b417..132501b60a240 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActions.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActions.ts @@ -33,6 +33,10 @@ export const MERGE_METHODS = { description: "Keep the commits and add a merge commit.", }, rebase: { title: "Rebase and merge", description: "Add the commits without a merge commit." }, + "fast-forward": { + title: "Fast-forward merge", + description: "Add the commits as they are. Needs the branch to contain the base.", + }, } as const satisfies Record; export function pullRequestActionLabel( diff --git a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts index 85a77a254967f..d79682f944886 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts @@ -3,7 +3,12 @@ import { visibleLinkedThreads } from "@t3tools/client-runtime/state/threads"; import { AsyncResult } from "effect/reactivity"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Dialog from "../../components/ui/Dialog"; -import { useNavigationState, useRoute } from "@react-navigation/native"; +import { + StackActions, + useNavigation, + useNavigationState, + useRoute, +} from "@react-navigation/native"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { AuthOrchestrationOperateScope, @@ -16,7 +21,6 @@ import { formatComposerContextReference } from "@t3tools/shared/composerContextR import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; import { useRef, useState } from "react"; -import { useOpenWorkspaceDetail } from "../layout/useOpenWorkspaceDetail"; import { pullRequestComposerContext } from "../../lib/composerContext"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { uuidv4 } from "../../lib/uuid"; @@ -82,14 +86,15 @@ export function usePullRequestThreads( const canOperate = useEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope); const canAsk = !connected || canOperate; const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope); - const openDetail = useOpenWorkspaceDetail(); + const navigation = useNavigation(); const pending = useRef(false); const previousPrompts = useRef(new Map()); const openThread = (thread: LinkedThread) => { openThreadRoute({ environmentId: params.environmentId, threadId: thread.id }); }; - const openThreadRoute = (destination: ReactNavigation.RootParamList["Thread"]) => - openDetail({ name: "Thread", params: destination }); + const openThreadRoute = (destination: ReactNavigation.RootParamList["Thread"]) => { + navigation.dispatch(StackActions.push("Thread", destination)); + }; const [busy, setBusy] = useState(false); const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => { if (!pr || !canAsk || pending.current) return; diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index d5fa085eb18fc..f2164bf49f99f 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -2,12 +2,12 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native" import { TextInputWrapper } from "expo-paste-input"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useMemo, useState } from "react"; -import { Platform, ScrollView, View, useWindowDimensions } from "react-native"; -import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ScrollView, View, useWindowDimensions } from "react-native"; +import * as Dialog from "../../components/ui/Dialog"; import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal"; import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; +import { KeyboardSheetBody } from "../../components/ui/KeyboardSheetBody"; import { SheetHeader } from "../../components/ui/SheetHeader"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { ControlPill } from "../../components/ui/ControlPill"; @@ -39,9 +39,7 @@ type ReviewCommentComposerSheetProps = StaticScreenProps<{ }>; export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) { - const isAndroid = Platform.OS === "android"; const navigation = useNavigation(); - const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); const { themeAppearance: selectedTheme } = useAppearancePreferences(); const target = useReviewCommentTarget(); @@ -89,13 +87,14 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp const handleNativePaste = useNativePaste((uris) => { void (async () => { try { - const images = await convertPastedImagesToAttachments({ + const result = await convertPastedImagesToAttachments({ uris, existingCount: attachments.length, }); - if (images.length > 0) { - setAttachments((current) => [...current, ...images]); + if (result.attachments.length > 0) { + setAttachments((current) => [...current, ...result.attachments]); } + if (result.error) Dialog.alert("Could not paste image", result.error); } catch (error) { console.error("[review comment] error converting pasted images", error); } @@ -130,167 +129,136 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp return ( - - - {!target ? ( - - No selection - - Select a diff line or range first. + + void handlePickImages()} + /> + + + + ) : undefined + } + > + {!target ? ( + + No selection + + Select a diff line or range first. + + + ) : ( + + + + {selectionLabel} + + + {target.filePath} - ) : ( - - - - {selectionLabel} - - - {target.filePath} - - - + + REVIEW_COMMENT_PREVIEW_MAX_LINES} + nestedScrollEnabled keyboardShouldPersistTaps="always" - showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={ + selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES + } + style={{ height: previewHeight }} > - REVIEW_COMMENT_PREVIEW_MAX_LINES} - nestedScrollEnabled - keyboardShouldPersistTaps="always" - showsVerticalScrollIndicator={ - selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES - } - style={{ height: previewHeight }} - > - - {selectedLines.map((line) => { - const lineNumber = getReviewUnifiedLineNumber(line); + + {selectedLines.map((line) => { + const lineNumber = getReviewUnifiedLineNumber(line); - return ( - - - - {lineNumber ?? ""} - - - - + return ( + + + + {lineNumber ?? ""} + + + - ); - })} - - + + ); + })} + - + + - - Comment - - - - - - - {attachments.length > 0 ? ( - - { - setAttachments((current) => - current.filter((image) => image.id !== imageId), - ); - }} - /> - - ) : null} + + Comment + + + + + + {attachments.length > 0 ? ( + + { + setAttachments((current) => + current.filter((image) => image.id !== imageId), + ); + }} + /> + + ) : null} - )} - - {!isAndroid && target ? ( - - void handlePickImages()} - /> - - - - ) : null} - - {isAndroid && target ? ( - - - void handlePickImages()} - /> - - - - ) : null} + )} + setPreviewFile(null)} /> ); diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 2673865359f0e..21bf033b761be 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -4,31 +4,28 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; import { LegendList } from "@legendapp/list/react-native"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { AppBar } from "../../components/ui/AppBar"; import type { AppBarMenuItem } from "../../components/ui/AppBar.types"; import type { ReviewSectionItem } from "./reviewModel"; import { useReviewHeaderPresentation } from "./useReviewHeaderPresentation"; import { type Ref, type ReactElement, useImperativeHandle, useRef, useState } from "react"; -import { - Platform, - Pressable, - RefreshControl, - ScrollView, - type NativeSyntheticEvent, - StyleSheet, - View, -} from "react-native"; +import { Pressable, ScrollView, type NativeSyntheticEvent, StyleSheet, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReduceMotionEnabled } from "../../lib/appState"; -import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; -import { useContentTopInset } from "../../components/ui/useBarInset"; +import { useContentTopInset, useSheetContentBottomInset } from "../../components/ui/useBarInset"; import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; +import { + ContentBand, + ContentStatus, + contentStatusContainerStyle, +} from "../../components/ui/ContentBand"; +import { PaneListItem } from "../../components/ui/PaneListItem"; import { ScreenContent } from "../../components/ui/ScreenContent"; -import { cn } from "../../lib/cn"; +import { SheetPane } from "../../components/ui/SheetPane"; import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -149,15 +146,10 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = function ReviewNotice(props: { readonly notice: string }) { return ( - + Partial diff {props.notice} - + ); }; @@ -242,21 +234,7 @@ const ReviewFileNavigatorRow = function ReviewFileNavigatorRow(props: { }; return ( - + - + ); }; @@ -334,7 +312,7 @@ function ReviewFileNavigator({ files, sectionId, onSelectFile, ref }: ReviewFile ); return ( - + - + ); } @@ -378,11 +356,8 @@ export function ReviewSheet(props: ReviewSheetProps) { const { draftMessage, draftContext } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const { selectedThreadCwd } = useSelectedThreadWorktree(); - const columnMetrics = useNativeColumnLayoutMetrics(); - const safeArea = columnMetrics?.safeArea ?? insets; const topContentInset = useContentTopInset(); - const bottomContentInset = - Platform.OS === "ios" ? safeArea.bottom + 8 : Math.max(insets.bottom, 18) + 18; + const bottomContentInset = useSheetContentBottomInset(); const { error, @@ -577,16 +552,10 @@ export function ReviewSheet(props: ReviewSheetProps) { if (error) { children.push( - + Review unavailable {error} - , + , ); } @@ -636,7 +605,7 @@ export function ReviewSheet(props: ReviewSheetProps) { /> - + {showConnectionNotice ? ( - No review diffs - - This thread has no ready turn diffs and the worktree diff is empty. - - + ) ) : selectedSection.isLoading && selectedSection.diff === null ? ( - - - Loading diff… - + ) : parsedDiff.kind === "empty" ? ( - - No changes - - {selectedSection.subtitle ?? "This diff is empty."} - - + ) : parsedDiff.kind === "raw" ? ( - + {parsedDiff.reason} @@ -787,14 +717,9 @@ export function ReviewSheet(props: ReviewSheetProps) { {parsedDiff.text} - + ) : parsedDiff.kind === "files" ? ( - + Native diff view unavailable. Showing the raw patch. @@ -803,7 +728,7 @@ export function ReviewSheet(props: ReviewSheetProps) { {selectedSection?.diff ?? ""} - + ) : null} )} diff --git a/apps/mobile/src/features/review/reviewFileVisibility.test.tsx b/apps/mobile/src/features/review/reviewFileVisibility.test.tsx new file mode 100644 index 0000000000000..ac983a81ab1c9 --- /dev/null +++ b/apps/mobile/src/features/review/reviewFileVisibility.test.tsx @@ -0,0 +1,69 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { expect, it, vi } from "vite-plus/test"; + +import { buildReviewParsedDiff, type ReviewRenderableFile } from "./reviewModel"; +import { useReviewFileVisibility } from "./reviewFileVisibility"; + +const stored = vi.hoisted(() => ({ expanded: undefined as ReadonlyArray | undefined })); +vi.mock("./reviewState", () => ({ + updateReviewExpandedFileIds: ( + _threadKey: string, + _sectionId: string, + update: (current: ReadonlyArray | undefined) => ReadonlyArray | undefined, + ) => { + stored.expanded = update(stored.expanded); + }, + updateReviewViewedFileIds: () => {}, +})); + +function patch(path: string, line: string) { + return `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-old\n+${line}\n`; +} + +function files(diff: string): ReadonlyArray { + const parsed = buildReviewParsedDiff(diff, "turn:3"); + if (parsed.kind !== "files") throw new Error("Expected structured diff"); + return parsed.files; +} + +function visibility(current: ReadonlyArray) { + let result: ReturnType | undefined; + function Probe({ report }: { report: (value: typeof result) => void }) { + report( + useReviewFileVisibility({ + threadKey: "thread", + sectionId: "turn:3", + files: current, + cachedExpandedFileIds: stored.expanded, + cachedViewedFileIds: undefined, + }), + ); + return null; + } + renderToStaticMarkup( (result = value)} />); + return result!; +} + +it("keeps files the reader left open expanded when the section's diff changes", () => { + stored.expanded = undefined; + const before = files(patch("a.ts", "one") + patch("b.ts", "two")); + visibility(before).toggleExpandedFile(before[0]!.id); + expect(visibility(before).collapsedFileIds).toEqual([before[0]!.id]); + + const after = files(patch("a.ts", "changed") + patch("b.ts", "two")); + const collapsedPaths = visibility(after).collapsedFileIds.map( + (id) => after.find((file) => file.id === id)!.path, + ); + expect(collapsedPaths).not.toContain("b.ts"); +}); + +it("folds only the selected entry when a file becomes a symlink", () => { + stored.expanded = undefined; + const current = files( + "diff --git a/a.ts b/a.ts\ndeleted file mode 100644\n--- a/a.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-old\n" + + "diff --git a/a.ts b/a.ts\nnew file mode 120000\n--- /dev/null\n+++ b/a.ts\n@@ -0,0 +1 @@\n+b.ts\n\\ No newline at end of file\n", + ); + expect(current).toHaveLength(2); + visibility(current).toggleExpandedFile(current[0]!.id); + expect(visibility(current).collapsedFileIds).toEqual([current[0]!.id]); +}); diff --git a/apps/mobile/src/features/review/reviewFileVisibility.ts b/apps/mobile/src/features/review/reviewFileVisibility.ts index 5d01de5700eda..7e1379d2b5ff5 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.ts @@ -3,10 +3,25 @@ import { useCallback, useMemo } from "react"; import { updateReviewExpandedFileIds, updateReviewViewedFileIds } from "./reviewState"; import type { ReviewRenderableFile } from "./reviewModel"; -function getDefaultReviewExpandedFileIds( +// File ids are scoped to one diff text, so a refresh renames every file. Expansion is +// stored by path, which survives a refresh; the occurrence index keeps apart entries +// that share a path, such as the deletion and addition when a file becomes a symlink. +function getReviewFileExpansionKeys( files: ReadonlyArray, ): ReadonlyArray { - return files.map((file) => file.id); + const seen = new Map(); + return files.map((file) => { + const occurrence = seen.get(file.path) ?? 0; + seen.set(file.path, occurrence + 1); + return `${occurrence}:${file.path}`; + }); +} + +function getExpandedReviewFileKeys( + keys: ReadonlyArray, + storedKeys: ReadonlyArray | undefined, +): ReadonlyArray { + return storedKeys === undefined ? keys : keys.filter((key) => storedKeys.includes(key)); } function getValidReviewFileIds( @@ -14,7 +29,7 @@ function getValidReviewFileIds( fileIds: ReadonlyArray | undefined, ): ReadonlyArray { if (fileIds === undefined) { - return getDefaultReviewExpandedFileIds(files); + return files.map((file) => file.id); } const fileIdSet = new Set(files.map((file) => file.id)); @@ -49,10 +64,13 @@ export function useReviewFileVisibility(input: { }) { const { cachedExpandedFileIds, cachedViewedFileIds, files, sectionId, threadKey } = input; - const expandedFileIds = useMemo( - () => getValidReviewFileIds(files, cachedExpandedFileIds), - [cachedExpandedFileIds, files], - ); + const expansionKeys = useMemo(() => getReviewFileExpansionKeys(files), [files]); + const expandedFileIds = useMemo(() => { + const expandedKeys = new Set(getExpandedReviewFileKeys(expansionKeys, cachedExpandedFileIds)); + return files + .filter((_, index) => expandedKeys.has(expansionKeys[index]!)) + .map((file) => file.id); + }, [cachedExpandedFileIds, expansionKeys, files]); const viewedFileIds = useMemo( () => getValidExplicitReviewFileIds(files, cachedViewedFileIds), [cachedViewedFileIds, files], @@ -73,11 +91,15 @@ export function useReviewFileVisibility(input: { return; } + const key = expansionKeys[files.findIndex((file) => file.id === fileId)]; + if (key === undefined) { + return; + } updateReviewExpandedFileIds(threadKey, sectionId, (existing) => - toggleReviewFileId(getValidReviewFileIds(files, existing), fileId), + toggleReviewFileId(getExpandedReviewFileKeys(expansionKeys, existing), key), ); }, - [files, sectionId, threadKey], + [expansionKeys, files, sectionId, threadKey], ); const toggleViewedFile = useCallback( @@ -91,13 +113,14 @@ export function useReviewFileVisibility(input: { toggleReviewFileId(getValidExplicitReviewFileIds(files, existing), fileId), ); - if (shouldCollapse) { + const key = expansionKeys[files.findIndex((file) => file.id === fileId)]; + if (shouldCollapse && key !== undefined) { updateReviewExpandedFileIds(threadKey, sectionId, (existing) => - removeReviewFileId(getValidReviewFileIds(files, existing), fileId), + removeReviewFileId(getExpandedReviewFileKeys(expansionKeys, existing), key), ); } }, - [files, sectionId, threadKey, viewedFileIds], + [expansionKeys, files, sectionId, threadKey, viewedFileIds], ); return { diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 75b0cd4e91669..d37d405280a0a 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from "@effect/atom-react"; import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import { useRef, useState } from "react"; -import { RefreshControl } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SettingsScreen } from "./components/SettingsScreen"; diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index e1dd36b994789..71be773aefcac 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -87,9 +87,7 @@ import { useScheduledTaskDestinationLabel, useScheduledTaskEditor } from "./sche import { discardScheduledTaskEditor, markScheduledTaskEditorSaved, - scheduledTaskEditorSessionAtom, } from "./scheduled-task-editor-state"; -import { appAtomRegistry } from "../../state/atom-registry"; import { describeSchedule, formatNextScheduledTaskRun, @@ -481,8 +479,6 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } const voiceInput = useVoiceInputController({ ownerKey, label: editor?.draft.title.trim() || title, - subscribeToDraftChanges: (onChange) => - appAtomRegistry.subscribe(scheduledTaskEditorSessionAtom, onChange), selection, readDraftMessage: () => { const current = readEditor(); diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 835cc55678fab..fba91ef003f46 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -596,6 +596,21 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { onValueChange={(value) => write({ continueThreadsAfterServerUpdate: value })} /> + + write({ keepAwakeWhileAgentsWork: value })} + /> + ) : null} diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 0999af8080fdb..7ee20c8562ee9 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -219,7 +219,7 @@ function emptyText( case "pull-request": return "No matching pull requests."; case "path": - return "No matching files or folders."; + return "No matching mentions."; case "skill": return "No skills found."; case "slash-command": @@ -334,7 +334,13 @@ const CommandRow = memo(function CommandRow(props: { type="monochrome" /> ) : null} - + {props.isSlashSkill && props.item.type === "skill" ? ( <> skill: diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index 229ef86273b8d..aee091d9f598e 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -299,8 +299,8 @@ function LinkRequestSheet(props: { > - Agents here can then launch, message and wait on threads there. It appears in that - machine's Connections, where it can be revoked. + Agents here can then launch, message and wait on threads there. It appears under + Authorized clients in that machine's Sharing & access, where it can be revoked. {item.url !== undefined && target.kind === "machine" ? ( @@ -375,7 +375,7 @@ function LinkRequestSheet(props: { {useExisting ? ( This environment is already linked to {targetName}. The agent can use that link as it - is; change its access in Settings → Connections. + is; change its access in Settings → Environments. ) : target.kind === "none" ? null : ( <> @@ -394,8 +394,8 @@ function LinkRequestSheet(props: { spellCheck={false} /> - Create a link on {targetName}: Settings → Connections → Create link, then paste - its code here. + Create a link on {targetName}: Settings → Sharing & access → Authorized clients → + Create link, then paste its code here. )} diff --git a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx index de3041c06b57e..747b9a9fc1867 100644 --- a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx +++ b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx @@ -56,6 +56,41 @@ function descendsFrom( /** Resolve apps from stored tool calls so links cannot supply apps the call never produced. */ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { + const navigation = useNavigation(); + const { environmentId, threadId, itemId } = route.params; + if (!environmentId?.trim() || !threadId?.trim() || !itemId?.trim()) { + return ( + + navigation.goBack()} + closeAccessibilityLabel="Exit full screen" + /> + This app cannot be shown. + + ); + } + return ( + + ); +} + +function McpAppFullscreen({ + params, + environmentId, + threadId, + itemId, +}: { + readonly params: McpAppFullscreenScreenProps["route"]["params"]; + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly itemId: TurnItemId; +}) { const navigation = useNavigation(); const insets = useSafeAreaInsets(); // Wait for focus before goBack; an exit under another screen must not pop that screen. @@ -74,10 +109,6 @@ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { }); }; const [size, setSize] = useState({ width: 0, height: 0 }); - const params = route.params; - const environmentId = EnvironmentId.make(params.environmentId); - const threadId = ThreadId.make(params.threadId); - const itemId = TurnItemId.make(params.itemId); const conversationThreadId = useConversationThreadId( environmentId, threadId, diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 4b77761b600a7..abb35e793edc0 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -90,7 +90,7 @@ const idleBranchCheckoutAtom = Atom.make(AsyncResult.initial) { const currentRoute = useRoute(); - const goBack = useWorkspaceBackNavigation("threads"); + const goBack = useWorkspaceBackNavigation(); const params = route.params ?? {}; const draftId = Array.isArray(params.draftId) ? params.draftId[0] : params.draftId; const projects = useProjects(); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index bcfc22434f9ba..71816bfa67be3 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -176,7 +176,7 @@ export function NewTaskDraftScreen(props: { const flow = useNewTaskFlow(); const navigation = useNavigation(); const isFocused = useIsFocused(); - const goBack = useWorkspaceBackNavigation("threads"); + const goBack = useWorkspaceBackNavigation(); const currentRoute = useRoute(); const flowKey = newTaskFlowKey(currentRoute); const { getShare, isLoading: isIncomingShareInboxLoading } = useIncomingShare(); diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 0793537aa0aea..12640fdb6e7e8 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -273,6 +273,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { threadId={props.threadId} workspaceRoot={props.workspaceRoot} setDirection={setDirection} + preserveSoftBreaks /> {question.options.map((option) => { diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index 2c369c1e5b7bc..b8fa753188608 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -109,14 +109,17 @@ export function QuestionAttachments(props: { uris, existingCount: appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0, }) - .then(async (images) => { + .then(async (result) => { if ( scope.active && (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0 ) { - if (append(key, images) > 0) - Dialog.alert("Could not paste image", "Too many attachments."); - } else await releaseUnusedComposerAttachmentFiles(images); + const problems = [ + ...(result.error ? [result.error] : []), + ...(append(key, result.attachments) > 0 ? ["Too many attachments."] : []), + ]; + if (problems.length) Dialog.alert("Could not paste image", problems.join("\n\n")); + } else await releaseUnusedComposerAttachmentFiles(result.attachments); }) .catch((error) => Dialog.alert( diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index 9ee2a37fcfd39..02dda6a1dd675 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -18,7 +18,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import { deriveThreadArrangementSizing } from "../../lib/layout"; import { environmentServerConfigsAtom } from "../../state/server"; import { threadListEnvironmentsAtom } from "../../state/presentation"; -import { environmentThreadShells } from "../../state/threads"; +import { steadyNavigationThreadShellsAtom, steadyShellsHoldingAtom } from "../../state/threads"; import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf"; import { @@ -171,7 +171,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { baseFontSize: appearance.baseFontSize, fontScale, }); - const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom); + const threads = useAtomValue(steadyNavigationThreadShellsAtom); const configs = useAtomValue(environmentServerConfigsAtom); const listEnvironments = useAtomValue(threadListEnvironmentsAtom); const { groupsShownInThreads, groupsHidingSettled, automationsHidingSettledEnvironmentIds } = @@ -179,6 +179,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom); const pendingOrder = useAtomValue(pendingThreadOrderAtom); const dropBusy = useAtomValue(threadDropBusyAtom); + const holdingRows = useAtomValue(steadyShellsHoldingAtom); const { moveThread } = useThreadListActions(); const { workingShelfEnabled } = useThreadListV2ShelfPreferences(); const now = useThreadListNow(threads); @@ -511,6 +512,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { disabled={ dropBusy || pendingOrder !== null || + holdingRows || (!listEnvironments.pinReorderEnvironmentIds.has( thread.environmentId, ) && diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 11e5e95a68b31..1e5afd8a28574 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -62,13 +62,11 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re import { scopedThreadKey } from "../../lib/scopedEntities"; import { getComposerDraftSnapshot, - composerDraftsAtom, setComposerDraftText, composerContextImportsAtom, countComposerDraftAttachmentsAfterSelection, useComposerDraft, } from "../../state/use-composer-drafts"; -import { appAtomRegistry } from "../../state/atom-registry"; import type { ComposerDocumentAttachment } from "../../lib/composerContext"; import { useProject, useProjects, useVisibleThreadShells } from "../../state/entities"; import { useMentionableEnvironments } from "../../state/environments"; @@ -662,7 +660,6 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ownerKey: composerDraftKey, label: props.selectedThread.title || "Untitled thread", readDraftMessage: () => getComposerDraftSnapshot(composerDraftKey).text, - subscribeToDraftChanges: (onChange) => appAtomRegistry.subscribe(composerDraftsAtom, onChange), selection: composerMenu.selection, onChangeDraftMessage: (text) => setComposerDraftText(composerDraftKey, text), onChangeSelection: composerMenu.onSelectionChange, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2f6d6ae368f83..81a712d040f54 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1086,11 +1086,16 @@ function ThreadDetailBody( windowHeight: screenAreaHeight, keyboardHeight: keyboardStickyEnabled ? -keyboardTranslation.value : 0, navigationHeaderHeight, - composerOverlapHeight: composerBottomInset, + // An in-flow question shares the room above it with the floating control. + composerOverlapHeight: + composerBottomInset + + (questionInFlow && showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0), }), ); + // A question pads itself clear of the bottom inset, below the card's own height limit. + const questionBottomInset = activeUserInputRequestId !== null ? composerBottomInset : 0; const composerPanelsHeightStyle = useAnimatedStyle(() => ({ - maxHeight: pendingUserInputMaxHeight.value, + maxHeight: pendingUserInputMaxHeight.value + questionBottomInset, flexGrow: 0, })); // Questions overlay the composer on iOS; an approval sits above it, so it gives up its height. diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f1317e85a4c50..50b100d4b2f22 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -241,9 +241,10 @@ import { ThreadMcpApp } from "./McpAppWebView"; import { assetEnvironment, useAssetUrl, useRefreshAssetUrl } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { readEnvironmentScope, usePreparedConnection } from "../../state/session"; -import { projectEnvironment } from "../../state/projects"; +import { environmentProjects, projectEnvironment } from "../../state/projects"; import { needsWorkspaceBasenameLookup, + pickProjectLinkMatch, pickWorkspaceLinkMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, workspaceLinkLookupQuery, @@ -271,6 +272,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { useV2ItemSupport } from "../../state/v2-item-support"; import { fileRoutePathSegments, + isAbsolutePath, resolveFileLinkPath, resolveWorkspaceFilePath, resolveWorkspaceRelativeFilePath, @@ -286,6 +288,7 @@ import { ThreadMarkdownImageUnavailable, ThreadMarkdownImageView, } from "./ThreadMarkdownImage"; +import { ZoomableImagePressable } from "./ZoomableImagePressable"; import { openSettings } from "../settings/settings-navigation"; import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; import { formatMessageTimestamp } from "./messageTimestamp"; @@ -480,6 +483,8 @@ function MessageImage(props: { readonly className: string; readonly maxWidth?: number; readonly height?: number; + readonly accessibilityLabel: string; + readonly onPress: () => void; readonly onLoad?: () => void; readonly onError?: () => void; }) { @@ -494,11 +499,13 @@ function MessageImage(props: { }) : null; return ( - { - if (props.maxWidth !== undefined) setSize(event.source); - props.onLoad?.(); - }} - onError={props.onError} - /> + > + { + if (props.maxWidth !== undefined) setSize(event.source); + props.onLoad?.(); + }} + onError={props.onError} + /> + ); } @@ -559,8 +572,12 @@ function MessageAttachmentImage(props: { return ( - props.onPressPreview( @@ -579,22 +596,15 @@ function MessageAttachmentImage(props: { }), ) } - > - { - retriedImage.current = false; - }} - onError={() => { - if (retriedImage.current) return; - retriedImage.current = true; - void refreshAssetUrl(); - }} - /> - + onLoad={() => { + retriedImage.current = false; + }} + onError={() => { + if (retriedImage.current) return; + retriedImage.current = true; + void refreshAssetUrl(); + }} + /> ); } @@ -969,21 +979,18 @@ function PendingMessageMedia(props: { if (isImageAttachment(attachment)) { const uri = fileBacked ? preview.uri : (attachment.dataUrl ?? attachment.previewUri); return ( - { const source = composerImageGallery([attachment])[0]; if (source) props.onPressPreview(withGallery(source)); }} - > - - + /> ); } if (preview.uri === null) { @@ -1726,6 +1733,7 @@ function renderFeedEntry( rowSizing={props.workRowSizing} iconSubtleColor={iconSubtleColor} label={entry.label} + providerStartingLabel={entry.providerStartingLabel} /> ); } @@ -2906,6 +2914,85 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // A v1 link names no environment, so it falls back to this message's. return scopeThreadRef(props.environmentId, link.threadId); }; + // The file a chat link names: in this workspace, else in the one other project that has it. + const findFileLinkTarget = async ( + linkPath: string, + path: string, + signal: AbortSignal, + ): Promise => { + const relativePath = resolveWorkspaceRelativeFilePath(props.workspaceRoot, path); + if ( + !props.workspaceRoot || + !relativePath || + /[\\/]$/.test(linkPath) || + workspaceLinkLookupQuery(linkPath, props.workspaceRoot) === null || + !readEnvironmentScope(props.environmentId, AuthFilesystemReadScope) + ) { + return relativePath ?? path; + } + const workspaceRoot = props.workspaceRoot; + const originalPath = resolveWorkspaceFilePath(workspaceRoot, relativePath); + const queryOptions = { signal, reportFailure: false, reportDefect: false }; + const searchRoot = (root: string) => + executeAtomQuery( + appAtomRegistry, + projectEnvironment.searchEntries({ + environmentId: props.environmentId, + input: { + cwd: root, + query: workspaceLinkLookupQuery(relativePath, root) ?? relativePath, + kind: "file", + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + }, + }), + queryOptions, + ).then((result) => ({ + root, + entries: result._tag === "Success" ? result.value.entries : [], + })); + const lookup = async () => { + const own = await searchRoot(workspaceRoot); + let match = pickWorkspaceLinkMatch(relativePath, workspaceRoot, own.entries); + const otherProject = match === null; + if (otherProject) { + const threadProjectId = appAtomRegistry.get( + environmentThreadShells.threadShellAtom( + scopeThreadRef(props.environmentId, props.threadId), + ), + )?.projectId; + const otherRoots = appAtomRegistry + .get(environmentProjects.projectsAtom) + .flatMap((project) => + project.environmentId === props.environmentId && + project.id !== threadProjectId && + project.workspaceRoot !== workspaceRoot + ? [project.workspaceRoot] + : [], + ); + match = + otherRoots.length === 0 + ? null + : pickProjectLinkMatch(relativePath, await Promise.all(otherRoots.map(searchRoot))); + } + if (!match || match === relativePath || signal.aborted) return relativePath; + const metadata = await executeAtomQuery( + appAtomRegistry, + projectEnvironment.fileMetadata({ + environmentId: props.environmentId, + input: { path: originalPath }, + }), + queryOptions, + ); + // Another project's file never replaces a thread-local file the search missed. + return metadata._tag === "Success" && + (needsWorkspaceBasenameLookup(relativePath) && !otherProject + ? metadata.value?.kind !== "directory" + : metadata.value === null) + ? match + : relativePath; + }; + return lookup().catch(() => relativePath); + }; const onMarkdownLinkPress = (href: string, imageEmbed = false) => { if (!imageEmbed && googlePreview.open(href)) return; markdownLinkRequest.current?.abort(); @@ -2974,8 +3061,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (presentation.kind === "file") { const path = resolveFileLinkPath(props.workspaceRoot, presentation.path); if (path === null) return; - const relativePath = resolveWorkspaceRelativeFilePath(props.workspaceRoot, path); - const openAt = (target: string) => { + void findFileLinkTarget(presentation.path, path, request.signal).then((target) => { if (request.signal.aborted) return; void Haptics.selectionAsync(); navigation.navigate("ThreadFile", { @@ -2984,62 +3070,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { path: fileRoutePathSegments(target), ...(presentation.line ? { line: String(presentation.line) } : {}), }); - }; - if ( - props.workspaceRoot && - relativePath && - !/[\\/]$/.test(presentation.path) && - workspaceLinkLookupQuery(presentation.path, props.workspaceRoot) !== null && - readEnvironmentScope(props.environmentId, AuthFilesystemReadScope) - ) { - const workspaceRoot = props.workspaceRoot; - const originalPath = resolveWorkspaceFilePath(props.workspaceRoot, relativePath); - void executeAtomQuery( - appAtomRegistry, - projectEnvironment.searchEntries({ - environmentId: props.environmentId, - input: { - cwd: workspaceRoot, - query: workspaceLinkLookupQuery(relativePath, workspaceRoot) ?? relativePath, - kind: "file", - limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, - }, - }), - { signal: request.signal, reportFailure: false, reportDefect: false }, - ).then( - (result) => { - const match = - result._tag === "Success" - ? pickWorkspaceLinkMatch(relativePath, workspaceRoot, result.value.entries) - : null; - if (!match || match === relativePath) { - openAt(relativePath); - return; - } - if (request.signal.aborted) return; - void executeAtomQuery( - appAtomRegistry, - projectEnvironment.fileMetadata({ - environmentId: props.environmentId, - input: { path: originalPath }, - }), - { signal: request.signal, reportFailure: false, reportDefect: false }, - ).then( - (metadata) => - openAt( - metadata._tag === "Success" && - (needsWorkspaceBasenameLookup(relativePath) - ? metadata.value?.kind !== "directory" - : metadata.value === null) - ? match - : relativePath, - ), - () => openAt(relativePath), - ); - }, - () => openAt(relativePath), - ); - } else openAt(relativePath ?? path); + }); return; } @@ -3121,8 +3152,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { switch (actionId) { case "copy-full-path": case "copy-relative-path": { - const clipboardPath = fileChipClipboardPath(target, actionId); - if (clipboardPath !== undefined) copyTextWithHaptic(clipboardPath); + const presentation = resolveMarkdownLinkPresentation(href); + const path = + presentation.kind === "file" + ? resolveFileLinkPath(props.workspaceRoot, presentation.path) + : null; + if (presentation.kind !== "file" || path === null) return; + markdownLinkRequest.current?.abort(); + const request = new AbortController(); + markdownLinkRequest.current = request; + void findFileLinkTarget(presentation.path, path, request.signal).then((found) => { + if (request.signal.aborted) return; + const fullPath = + props.workspaceRoot && !isAbsolutePath(found) + ? resolveWorkspaceFilePath(props.workspaceRoot, found) + : found; + const clipboardPath = fileChipClipboardPath( + { + ...target, + ...(target.fullPath ? { fullPath } : {}), + ...(target.relativePath + ? { + relativePath: + resolveWorkspaceRelativeFilePath(props.workspaceRoot, fullPath) ?? + target.relativePath, + } + : {}), + }, + actionId, + ); + if (clipboardPath !== undefined) copyTextWithHaptic(clipboardPath); + }); return; } case "open-file": @@ -3357,7 +3417,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const jumpRunId = jumpReady && !presentedFeed.some((entry) => entry.type === "message" && entry.id === jumpToMessageId) - ? messageFoldRunId(props.feed, jumpToMessageId) + ? messageFoldRunId(props.feed, jumpToMessageId, presentedFeed) : null; if (jumpRunId && !expandedTurnIds.has(jumpRunId)) { setInteractionState((current) => ({ diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx index d0045bf5f284e..0388bfb299299 100644 --- a/apps/mobile/src/features/threads/ThreadHeader.tsx +++ b/apps/mobile/src/features/threads/ThreadHeader.tsx @@ -22,7 +22,7 @@ export function ThreadHeader(props: { readonly inspectorMode: ThreadInspectorMode | null; readonly onOpenFilesInspector: () => void; }) { - const goBack = useWorkspaceBackNavigation("threads"); + const goBack = useWorkspaceBackNavigation(); const { toggleAuxiliaryPane, startNewTask } = useAdaptiveWorkspaceLayout(); const { panes } = useWorkspacePaneState(); const gitMenu = useThreadGitMenuDefinition(props.gitControls); diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx index 209cccf8d2ef0..82f5cc62a7b68 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx @@ -3,19 +3,71 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; +type HandlerProps = Record void) | undefined>; +type GestureHandlers = Record void>; +type Transform = [{ translateX: number }, { translateY: number }, { scale: number }]; + const harness = vi.hoisted(() => ({ failImage: () => {}, loadImage: (_event: { source: { width: number; height: number } }) => {}, Passthrough: ({ children }: { children?: unknown }) => children, + pressable: {} as HandlerProps, + gestures: {} as Record, + contentStyle: () => ({}) as { transform?: Transform }, })); -vi.mock("react-native", () => ({ - Pressable: harness.Passthrough, - StyleSheet: { absoluteFill: {} }, - View: ({ children, style }: { children?: ReactNode; style?: unknown }) => ( +vi.mock("react-native", () => { + const View = ({ children, style }: { children?: ReactNode; style?: unknown }) => (
{children}
- ), -})); + ); + return { + Platform: { OS: "ios" }, + Pressable: (props: HandlerProps & { children?: ReactNode; style?: unknown }) => { + harness.pressable = props; + return {props.children}; + }, + StyleSheet: { absoluteFill: {} }, + View, + }; +}); +vi.mock("react-native-gesture-handler", () => { + // Records each gesture's callbacks so a test can drive the worklets with native-shaped events. + const builder = (kind: string) => () => { + const handlers: GestureHandlers = {}; + harness.gestures[kind] = handlers; + const gesture: object = new Proxy( + {}, + { + get: (_target, name) => (callback: GestureHandlers[string]) => { + if (typeof name === "string" && name.startsWith("on")) handlers[name] = callback; + return gesture; + }, + }, + ); + return gesture; + }; + return { + Gesture: { Pinch: builder("pinch"), Pan: builder("pan"), Simultaneous: () => ({}) }, + GestureDetector: ({ children }: { children?: ReactNode }) => children, + }; +}); +vi.mock("react-native-reanimated", async () => { + const { useState } = await import("react"); + return { + default: { View: ({ children }: { children?: ReactNode }) =>
{children}
}, + runOnJS: (callback: () => void) => callback, + // Reads the UI-thread style on demand, as Reanimated would after a gesture frame. + useAnimatedStyle: (updater: () => { transform?: Transform }) => { + harness.contentStyle = updater; + return {}; + }, + useSharedValue: (initial: T) => + useState(() => { + let value = initial; + return { get: () => value, set: (next: T) => void (value = next) }; + })[0], + }; +}); vi.mock("expo-image", () => ({ Image: (props: { onError: () => void; @@ -203,3 +255,146 @@ it("does not parse an obsolete SVG response after its last reader unmounts", asy registry.dispose(); } }); + +const zoom = () => { + const [{ translateX }, { translateY }, { scale }] = harness.contentStyle().transform!; + // Clamping at fit can yield -0, which is the same position. + return { scale, x: translateX + 0, y: translateY + 0 }; +}; +const pinch = (phase: "onStart" | "onUpdate", focalX: number, focalY: number, scale: number) => + act(async () => harness.gestures.pinch![phase]!({ focalX, focalY, scale, numberOfPointers: 2 })); +const endPinch = () => act(async () => harness.gestures.pinch!.onFinalize!({})); +const layout = (width: number, height: number) => + act(async () => harness.pressable.onLayout?.({ nativeEvent: { layout: { width, height } } })); +const markdownImage = (sourceKey: string, onPressPreview: () => void = () => {}) => ( + + + +); + +it("does not open the preview when a touch on the inline image pinches or pans it", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + const onPressPreview = vi.fn(); + const touch = (fingers: number) => + act(async () => + harness.pressable.onTouchStart?.({ + nativeEvent: { touches: Array.from({ length: fingers }, () => ({})) }, + }), + ); + const release = () => + act(async () => harness.pressable.onPress?.({ nativeEvent: { identifier: 1 } })); + try { + await act(async () => root.render(markdownImage("hero", onPressPreview))); + await touch(1); + await touch(2); + await release(); + expect(onPressPreview).not.toHaveBeenCalled(); + + await touch(1); + await act(async () => harness.gestures.pan!.onStart!({ translationX: 5, translationY: 0 })); + await release(); + expect(onPressPreview).not.toHaveBeenCalled(); + + await touch(1); + await release(); + expect(onPressPreview).toHaveBeenCalledOnce(); + + // VoiceOver activation has no touch, so an earlier pinch must not swallow it. + await touch(2); + await act(async () => harness.pressable.onPress?.({ nativeEvent: {} })); + expect(onPressPreview).toHaveBeenCalledTimes(2); + } finally { + await act(async () => root.unmount()); + } +}); + +it("zooms the inline image around the pinch and keeps it until the source or frame changes", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + const touchPan = () => { + const manager = { fail: vi.fn() }; + harness.gestures.pan!.onTouchesDown!({}, manager); + return manager; + }; + try { + await act(async () => root.render(markdownImage("a"))); + // The frame center is (172.5, 101); the pinch is off center. + await layout(345, 202); + await pinch("onStart", 300, 150, 1); + await pinch("onUpdate", 300, 150, 2); + expect(zoom()).toEqual({ scale: 2, x: -127.5, y: -49 }); + await pinch("onUpdate", 280, 140, 2); + expect(zoom()).toEqual({ scale: 2, x: -147.5, y: -59 }); + await act(async () => + harness.gestures.pinch!.onUpdate!({ focalX: 90, focalY: 90, scale: 2, numberOfPointers: 1 }), + ); + expect(zoom()).toEqual({ scale: 2, x: -147.5, y: -59 }); + await endPinch(); + + await act(async () => harness.loadImage({ source: { width: 1000, height: 585 } })); + await act(async () => root.render(markdownImage("a"))); + await layout(345, 202); + expect(zoom()).toEqual({ scale: 2, x: -147.5, y: -59 }); + + await pinch("onStart", 100, 50, 1); + await pinch("onUpdate", 100, 50, 1.5); + expect(zoom()).toEqual({ scale: 3, x: -185, y: -63 }); + await pinch("onUpdate", 100, 50, 10); + expect(zoom().scale).toBe(6); + await endPinch(); + + const zoomedPan = touchPan(); + await act(async () => harness.gestures.pan!.onStart!({ translationX: 5, translationY: 0 })); + await act(async () => + harness.gestures.pan!.onUpdate!({ translationX: 5000, translationY: -5000 }), + ); + expect(zoomedPan.fail).not.toHaveBeenCalled(); + expect(zoom()).toEqual({ scale: 6, x: 862.5, y: -505 }); + + await pinch("onStart", 172, 100, 1); + await pinch("onUpdate", 172, 100, 0.01); + await endPinch(); + expect(zoom()).toEqual({ scale: 1, x: 0, y: 0 }); + expect(touchPan().fail).toHaveBeenCalledOnce(); + + await pinch("onStart", 300, 150, 1); + await pinch("onUpdate", 300, 150, 2); + await endPinch(); + await layout(300, 175); + expect(zoom()).toEqual({ scale: 1, x: 0, y: 0 }); + + await pinch("onStart", 300, 150, 1); + await pinch("onUpdate", 300, 150, 2); + await endPinch(); + await act(async () => root.render(markdownImage("b"))); + expect(zoom()).toEqual({ scale: 1, x: 0, y: 0 }); + + for (const panFirst of [true, false]) { + await act(async () => root.render(markdownImage(`handoff-${panFirst}`))); + await layout(300, 200); + await pinch("onStart", 150, 100, 1); + await pinch("onUpdate", 150, 100, 2); + await endPinch(); + await act(async () => harness.gestures.pan!.onStart!({ translationX: 0, translationY: 0 })); + await pinch("onStart", 150, 100, 1); + const panUpdate = () => + act(async () => harness.gestures.pan!.onUpdate!({ translationX: 10, translationY: 0 })); + if (panFirst) await panUpdate(); + await pinch("onUpdate", 150, 100, 1.5); + if (!panFirst) await panUpdate(); + await endPinch(); + await act(async () => harness.gestures.pan!.onUpdate!({ translationX: 20, translationY: 0 })); + expect(zoom()).toEqual({ scale: 3, x: 10, y: 0 }); + } + } finally { + await act(async () => root.unmount()); + } +}); diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index da9ba935838c0..90eb97f4e3b8a 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -20,6 +20,7 @@ import { useMediaActions } from "../../state/mediaActions"; import type { MediaActionsSource } from "../../lib/mediaActionsSource"; import { useAssetUrlState } from "../../state/assets"; import { WorkspaceMutationIdContext } from "./workspaceMutationIdContext"; +import { ZoomableImagePressable } from "./ZoomableImagePressable"; import { assetImageCacheKey } from "../../lib/assetImageSource"; import { MARKDOWN_IMAGE_MAX_WIDTH, @@ -167,7 +168,8 @@ export function ThreadMarkdownImageView(props: { ) : ( - - - {props.format === "svg" ? ( - - ) : ( - - // Only the first decode counts: later loads report the bitmap - // downsampled for the current frame (969 vs 970 wide), so keeping - // them resizes the frame, which reloads the image, and so on. - setDecoded((current) => - current?.sourceKey === props.sourceKey - ? current - : { sourceKey: props.sourceKey, size }, - ) - } - onError={() => setFailure({ uri: props.uri, failed: true })} - /> - )} - - + {props.format === "svg" ? ( + + ) : ( + + // Only the first decode counts: later loads report the bitmap + // downsampled for the current frame (969 vs 970 wide), so keeping + // them resizes the frame, which reloads the image, and so on. + setDecoded((current) => + current?.sourceKey === props.sourceKey + ? current + : { sourceKey: props.sourceKey, size }, + ) + } + onError={() => setFailure({ uri: props.uri, failed: true })} + /> + )} + )} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 887dd4371bd65..2e06a8fbc91ff 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -28,8 +28,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { Button } from "../../components/ui/Button"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; -import { useProjects, useNavigationThreadShells } from "../../state/entities"; +import { useProjects } from "../../state/entities"; import { useHiddenTabThreads } from "./useHiddenTabThreads"; +import { steadyNavigationThreadShellsAtom } from "../../state/threads"; import { useThreadSearch } from "../../state/queries"; import { useThreadListPages } from "./use-thread-list-pages"; import { scopeProjectRefsByOrganisations } from "../home/thread-list-organisations"; @@ -129,7 +130,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { const insets = useSafeAreaInsets(); const { fabClearance, fabSize } = useAndroidControlSizing(); const projects = useProjects(); - const allThreads = useNavigationThreadShells(); + const allThreads = useAtomValue(steadyNavigationThreadShellsAtom); const { hiddenTabThreads, groupNames: tabGroupNames } = useHiddenTabThreads(allThreads); const threads = (() => allThreads diff --git a/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx b/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx index 6f8daba97f516..dc4280c8ed5db 100644 --- a/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx @@ -1,6 +1,6 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { LegendList } from "@legendapp/list/react-native"; -import { Platform, View } from "react-native"; +import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; @@ -40,7 +40,7 @@ export function ThreadPullRequestsSheet( const title = `Linked ${actions.terminology.plural}`; const content = ( - + {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- Sheet content; sheets sit outside workspace columns and keep their own header handling. */} diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index 00f588886fa76..ec6c4654ef8d5 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -1,7 +1,5 @@ import { useEnvironmentQuery } from "../../state/query"; import { messageDraftEnvironment, messageDraftFailureMessage } from "../../state/messageDrafts"; -import * as Dialog from "../../components/ui/Dialog"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; import { useAtomValue } from "@effect/atom-react"; import { @@ -9,6 +7,10 @@ import { queuedRunEditBlockedReason, } from "@t3tools/client-runtime/queued-run-edit"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import type { ChatAttachment, EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; @@ -24,6 +26,7 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "../../components/ui/Button"; +import * as Dialog from "../../components/ui/Dialog"; import { SheetHeader } from "../../components/ui/SheetHeader"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; @@ -147,6 +150,10 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { messageDraftEnvironment.drafts({ environmentId: target.environmentId, input: {} }), ).data; const canSaveDrafts = canSaveDraftsHere && loadedDrafts !== null; + const resumeHeldQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, { + label: "resume queue", + reportFailure: false, + }); const [resuming, setResuming] = useState(false); const [busyRunId, setBusyRunId] = useState(null); const busyRef = useRef(false); @@ -253,9 +260,20 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { if (busyRef.current) return; busyRef.current = true; setResuming(true); - await resume({ ...target, input: { threadId: target.threadId } }); + const result = await resumeHeldQueue({ + ...target, + input: { threadId: target.threadId }, + }); busyRef.current = false; setResuming(false); + // A usage limit holds the queue until the composer's Resume thread continues the run. + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert( + "Could not resume queue", + error instanceof Error ? error.message : "Try again.", + ); + } }} /> diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index e391aae19b56a..d9d2bb72a1dcc 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -159,7 +159,7 @@ function firstRouteParam(value: string | string[] | undefined): string | null { const InspectorHeaderShownContext = createContext(false); function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) { - const goBack = useWorkspaceBackNavigation("threads"); + const goBack = useWorkspaceBackNavigation(); const inspectorHeaderShown = use(InspectorHeaderShownContext); return ( {content} @@ -208,9 +211,10 @@ function DisclosureRow(props: { accessibilityRole="button" onPress={props.onPress} className={cn( - "min-h-11 flex-row items-center gap-2 bg-grouped-card px-4 py-2 active:bg-subtle android:min-h-14", + "flex-row items-center gap-2 bg-grouped-card px-4 py-2 active:bg-subtle", !props.isLast && "border-b border-border-subtle", )} + style={{ minHeight: ROW_MIN_HEIGHT.item }} > {props.label} @@ -1048,15 +1052,11 @@ function ThreadSettingsMainContent(props: { alwaysBounceVertical automaticallyAdjustsScrollIndicatorInsets className="flex-1 bg-sheet" - style={ - Platform.OS === "android" - ? { width: "100%", maxWidth: 720, alignSelf: "center" } - : undefined - } + style={READABLE_WIDTH_STYLE} contentContainerStyle={{ paddingTop: 4 }} contentInsetAdjustmentBehavior="automatic" data={listItems} - estimatedItemSize={Platform.OS === "android" ? 56 : 48} + estimatedItemSize={ROW_MIN_HEIGHT.item} extraData={animationsReady} getItemType={(item) => item.kind} itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION} @@ -1151,11 +1151,7 @@ function ThreadSettingsChoiceContent(props: { return ( , "children"> & { + readonly children: ReactNode; +}; + +/** + * Inline chat image that pinch-zooms in place on iOS. Key it by image source so a + * new source starts at fit; ordinary rerenders keep the zoom and offset. + */ +export function ZoomableImagePressable(props: ZoomableImagePressableProps) { + if (Platform.OS !== "ios") return ; + return ; +} + +function IosZoomableImagePressable({ + children, + onTouchStart, + onPress, + onLayout, + ...pressableProps +}: ZoomableImagePressableProps) { + const gestured = useRef(false); + const layoutSize = useRef(null); + const zoom = useSharedValue(FIT); + const size = useSharedValue({ width: 0, height: 0 }); + const pinching = useSharedValue(false); + const pinchStart = useSharedValue({ zoom: FIT, focalX: 0, focalY: 0, scale: 1, pointers: 0 }); + const panStart = useSharedValue({ zoom: FIT, translationX: 0, translationY: 0 }); + + const markGestured = useEffectEvent(() => { + gestured.current = true; + }); + const [gesture] = useState(() => { + // Focal points arrive in the untransformed frame; the transform is about its center. + const pinch = Gesture.Pinch() + .onStart((event) => { + const frame = size.get(); + pinching.set(true); + pinchStart.set({ + zoom: zoom.get(), + focalX: event.focalX - frame.width / 2, + focalY: event.focalY - frame.height / 2, + scale: event.scale, + pointers: event.numberOfPointers, + }); + runOnJS(markGestured)(); + }) + .onUpdate((event) => { + const frame = size.get(); + const start = pinchStart.get(); + const focalX = event.focalX - frame.width / 2; + const focalY = event.focalY - frame.height / 2; + if (event.numberOfPointers !== start.pointers) { + // A lifted or added finger jumps the focal point; continue from here instead. + pinchStart.set({ + zoom: zoom.get(), + focalX, + focalY, + scale: event.scale, + pointers: event.numberOfPointers, + }); + return; + } + const scale = Math.min( + Math.max((start.zoom.scale * event.scale) / start.scale, 1), + MAX_INLINE_ZOOM, + ); + // Keep the content point that started under the fingers under them. + const contentX = (start.focalX - start.zoom.x) / start.zoom.scale; + const contentY = (start.focalY - start.zoom.y) / start.zoom.scale; + zoom.set( + clampZoom({ scale, x: focalX - scale * contentX, y: focalY - scale * contentY }, frame), + ); + }) + .onFinalize(() => { + panStart.set({ ...panStart.get(), zoom: zoom.get() }); + pinching.set(false); + }); + const pan = Gesture.Pan() + .minDistance(ZOOMED_PAN_MIN_DISTANCE) + .onTouchesDown((_event, manager) => { + // At fit, a one-finger drag belongs to the feed. + if (zoom.get().scale <= 1) manager.fail(); + }) + .onStart((event) => { + panStart.set({ + zoom: zoom.get(), + translationX: event.translationX, + translationY: event.translationY, + }); + runOnJS(markGestured)(); + }) + .onUpdate((event) => { + if (pinching.get()) { + // The pinch owns the transform; resume panning from wherever it leaves it. + panStart.set({ + zoom: zoom.get(), + translationX: event.translationX, + translationY: event.translationY, + }); + return; + } + const start = panStart.get(); + zoom.set( + clampZoom( + { + scale: start.zoom.scale, + x: start.zoom.x + event.translationX - start.translationX, + y: start.zoom.y + event.translationY - start.translationY, + }, + size.get(), + ), + ); + }); + return Gesture.Simultaneous(pinch, pan); + }); + const contentStyle = useAnimatedStyle(() => { + const { scale, x, y } = zoom.get(); + return { transform: [{ translateX: x }, { translateY: y }, { scale }] }; + }); + + return ( + + { + gestured.current = event.nativeEvent.touches.length > 1; + onTouchStart?.(event); + }} + onPress={(event) => { + // Accessibility activation carries no touch identifier and no gesture. + if (typeof event.nativeEvent.identifier === "number" && gestured.current) return; + onPress?.(event); + }} + onLayout={(event) => { + const { width, height } = event.nativeEvent.layout; + const previous = layoutSize.current; + if (previous?.width !== width || previous?.height !== height) { + // The old offsets no longer fit the new frame, so a resize starts over at fit. + layoutSize.current = { width, height }; + size.set({ width, height }); + zoom.set(FIT); + } + onLayout?.(event); + }} + > + {children} + + + ); +} diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx index b2d29db8c8c72..436fb4dd0b403 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx @@ -8,25 +8,26 @@ const checkout = vi.hoisted(() => ({ branches: [] as Array<{ name: string; worktreePath: string | null }>, })); -vi.mock("react-native", () => ({ - Platform: { OS: "android" }, - View: ({ children }: { children: ReactNode }) =>
{children}
, - ScrollView: ({ children }: { children: ReactNode }) =>
{children}
, - Pressable: ({ children, disabled }: { children: ReactNode; disabled: boolean }) => ( - - ), - useWindowDimensions: () => ({ height: 800 }), -})); vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({}) })); -vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); vi.mock("../../../components/ui/SheetHeader", () => ({ SheetHeader: () => null })); -vi.mock("../../../components/ui/ScreenContent", () => ({ - ScreenContent: ({ children }: { children: ReactNode }) =>
{children}
, +vi.mock("../../../components/ui/FitSheet", () => ({ + FitSheet: ({ children }: { children: ReactNode }) =>
{children}
, })); vi.mock("../../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); -vi.mock("../../../components/ui/AppText", () => ({ - AppText: ({ children }: { children: ReactNode }) => {children}, - AppTextInput: () => null, +vi.mock("../../../components/ui/FormSection", () => ({ + FormSection: () => null, + FormField: () => null, +})); +vi.mock("../../../components/ui/ListSection", () => ({ + ListSection: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../../../components/ui/ListChoiceRow", () => ({ + ListChoiceRow: (props: { label: string; description: string; disabled: boolean }) => ( + + ), })); vi.mock("./gitSheetComponents", () => ({ SheetActionButton: () => null })); vi.mock("../../../state/entities", () => ({})); diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx index 6bd008116babb..fef45b5c88003 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx @@ -17,28 +17,20 @@ const state = vi.hoisted(() => ({ })); vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({ goBack() {} }) })); -vi.mock("react-native", () => ({ - Platform: { OS: "android" }, - useWindowDimensions: () => ({ height: 800 }), - View: ({ children }: { children: ReactNode }) => children, - ScrollView: ({ children }: { children: ReactNode }) => children, - Pressable: () => null, -})); -vi.mock("react-native-safe-area-context", () => ({ - useSafeAreaInsets: () => ({ bottom: 0 }), -})); vi.mock("../../../components/ui/SheetHeader", () => ({ SheetHeader: () => null })); -vi.mock("../../../components/ui/ScreenContent", () => ({ - ScreenContent: ({ children }: { children: ReactNode }) => children, +vi.mock("../../../components/ui/FitSheet", () => ({ + FitSheet: ({ children }: { children: ReactNode }) => children, })); vi.mock("../../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); -vi.mock("../../../components/ui/AppText", () => ({ - AppText: ({ children }: { children: ReactNode }) => children, - AppTextInput: (props: InputProps) => { +vi.mock("../../../components/ui/FormSection", () => ({ + FormSection: ({ children }: { children: ReactNode }) => children, + FormField: (props: InputProps) => { state.inputs.set(props.accessibilityLabel, props); return null; }, })); +vi.mock("../../../components/ui/ListSection", () => ({ ListSection: () => null })); +vi.mock("../../../components/ui/ListChoiceRow", () => ({ ListChoiceRow: () => null })); vi.mock("../../../state/query", () => ({ useEnvironmentQuery: () => ({ data: state.refName === null ? undefined : { refName: state.refName }, diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 67d0818ee6c4d..0f3d785b41041 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -1,13 +1,12 @@ import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useState } from "react"; -import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SheetHeader } from "../../../components/ui/SheetHeader"; -import { ScreenContent } from "../../../components/ui/ScreenContent"; -import { AppText as Text, AppTextInput as TextInput } from "../../../components/ui/AppText"; -import { cn } from "../../../lib/cn"; +import { FitSheet } from "../../../components/ui/FitSheet"; +import { FormField, FormSection } from "../../../components/ui/FormSection"; +import { ListChoiceRow } from "../../../components/ui/ListChoiceRow"; +import { ListSection } from "../../../components/ui/ListSection"; import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; @@ -23,8 +22,6 @@ type GitBranchesSheetProps = StaticScreenProps<{ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const { height: windowHeight } = useWindowDimensions(); const { selectedThread } = useThreadSelection(); const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); @@ -62,160 +59,113 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const disabledExistingBranches = new Set(disabledExistingBranchNames); return ( - navigation.goBack()} />} + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={false} > - - + + { + const branch = sanitizeFeatureBranchName(newBranchName.trim()); + if (branch.length === 0) return; + void gitActions.onCreateSelectedThreadBranch(branch).then((result) => { + if (result === null) return; + setNewBranchName(""); + navigation.goBack(); + }); + }} + /> + + + + + + - - - New branch - - - { + const baseBranch = worktreeBaseBranch.trim(); + const newBranch = worktreeBranchName.trim(); + if (baseBranch.length === 0 || newBranch.length === 0) return; + void gitActions + .onCreateSelectedThreadWorktree({ baseBranch, newBranch }) + .then((result) => { + if (result === null) return; + setWorktreeBranchName(""); + navigation.goBack(); + }); + }} + /> + + + + {availableBranches.map((branch, index) => { + const disabled = disabledExistingBranches.has(branch.name); + const subtitle = branch.worktreePath + ? branch.worktreePath === currentWorktreePath + ? "Checked out in this thread" + : "Checked out in another worktree" + : branch.isDefault + ? "Default branch" + : "Local branch"; + + return ( + 0} + disabled={!canChangeThreadBranch || busy || disabled} onPress={() => { - const branch = sanitizeFeatureBranchName(newBranchName.trim()); - if (branch.length === 0) return; - void gitActions.onCreateSelectedThreadBranch(branch).then((result) => { + void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => { if (result === null) return; - setNewBranchName(""); navigation.goBack(); }); }} /> - - - - - New worktree - - {Platform.OS === "android" ? ( - Base branch - ) : null} - - {Platform.OS === "android" ? ( - New branch - ) : null} - - { - const baseBranch = worktreeBaseBranch.trim(); - const newBranch = worktreeBranchName.trim(); - if (baseBranch.length === 0 || newBranch.length === 0) return; - void gitActions - .onCreateSelectedThreadWorktree({ baseBranch, newBranch }) - .then((result) => { - if (result === null) return; - setWorktreeBranchName(""); - navigation.goBack(); - }); - }} - /> - - - - - Existing branches - - {branchesLoading ? ( - - Loading branches... - - ) : null} - {!branchesLoading && availableBranches.length === 0 ? ( - - No local branches found. - - ) : null} - {availableBranches.map((branch) => { - const disabled = disabledExistingBranches.has(branch.name); - const subtitle = branch.worktreePath - ? branch.worktreePath === currentWorktreePath - ? "Checked out in this thread" - : "Checked out in another worktree" - : branch.isDefault - ? "Default branch" - : "Local branch"; - - return ( - { - void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => { - if (result === null) return; - navigation.goBack(); - }); - }} - > - {Platform.OS !== "android" ? ( - - ) : null} - - {branch.name} - - {subtitle} - - ); - })} - -
-
- navigation.goBack()} /> - + ); + })} + + ); } diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 637dab6768cf8..fb1f7a19af670 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -1,11 +1,10 @@ -import { ScreenContent } from "../../../components/ui/ScreenContent"; +import { FitSheet } from "../../../components/ui/FitSheet"; import { SheetHeader } from "../../../components/ui/SheetHeader"; import { Button } from "../../../components/ui/Button"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useState } from "react"; -import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; +import { Pressable, View } from "react-native"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../../components/ui/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/ui/AppText"; @@ -24,8 +23,6 @@ type GitCommitSheetProps = StaticScreenProps<{ export function GitCommitSheet(_props: GitCommitSheetProps) { const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const { height: windowHeight } = useWindowDimensions(); const { selectedThread } = useThreadSelection(); const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); @@ -66,168 +63,149 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { }); }; - const android = Platform.OS === "android"; - const header = ( - navigation.goBack()} - accessibilityLabel="Cancel commit" - /> - ); - return ( - // Material sheet drags follow the first nested-scrolling native view, and the inline - // header's Compose button is one, so Android renders content first and reverses the column. - navigation.goBack()} + accessibilityLabel="Cancel commit" + /> + } + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + automaticallyAdjustKeyboardInsets + contentContainerClassName="px-5 pt-4 gap-5" > - - - {isDefaultRef ? ( - - You’re on the default branch. You can create a new branch when committing. - - ) : null} - - Commit message - + {isDefaultRef ? ( + + You’re on the default branch. You can create a new branch when committing. + + ) : null} + + Commit message + + + Leave empty to generate a message automatically. + + + + + + Changed files - Leave empty to generate a message automatically. + {selectedFiles.length} of {allFiles.length} selected · + + {formatDiffCount(selectedInsertions)} −{formatDiffCount(selectedDeletions)} - - - - Changed files - - {selectedFiles.length} of {allFiles.length} selected · + - {formatDiffCount(selectedInsertions)} −{formatDiffCount(selectedDeletions)} - - - {allFiles.length > 0 ? ( -