diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs
deleted file mode 100644
index 94a02b7806dc..000000000000
--- a/.github/scripts/thread-transfer-report.cjs
+++ /dev/null
@@ -1,429 +0,0 @@
-const fs = require("node:fs");
-const path = require("node:path");
-
-const ARTIFACT_NAME = "thread-transfer-results";
-const RESULT_FILE = "thread-transfer-result.json";
-const COMMENT_MARKER = "";
-const PROVIDERS = ["codex", "claudeAgent"];
-const OBSERVED_KEYS = [
- "totalWireBytes",
- "threadSnapshotWireBytes",
- "threadSnapshotDecodedBytes",
- "measuredTurnWebSocketWireBytes",
- "measuredTurnWebSocketDecodedBytes",
- "measuredTurnWebSocketMessages",
-];
-const CEILING_KEYS = [
- "totalWireBytes",
- "threadSnapshotWireBytes",
- "measuredTurnWebSocketWireBytes",
- "measuredTurnWebSocketDecodedBytes",
- "measuredTurnWebSocketMessages",
-];
-const SCENARIO_KEYS = [
- "id",
- "historyTurns",
- "historyCommandToolsPerTurn",
- "historyMcpResultBytes",
- "measuredCommandTools",
- "measuredMcpResultBytes",
-];
-
-function resultShaMarker(sha) {
- return ``;
-}
-
-function assertObject(value, label) {
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
- throw new Error(`${label} must be an object`);
- }
-}
-
-function assertExactKeys(value, expected, label) {
- assertObject(value, label);
- const actual = Object.keys(value).sort();
- const wanted = [...expected].sort();
- if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
- throw new Error(`${label} has unexpected fields`);
- }
-}
-
-function assertMetric(value, label) {
- if (!Number.isSafeInteger(value) || value < 0 || value > 1_000_000_000) {
- throw new Error(`${label} must be a non-negative safe integer below 1,000,000,000`);
- }
-}
-
-function validateResult(value) {
- assertExactKeys(value, ["schemaVersion", "scenario", "providers"], "result");
- if (value.schemaVersion !== 1) {
- throw new Error("result.schemaVersion must be 1");
- }
-
- assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario");
- if (value.scenario.id !== "thread-transfer-v1") {
- throw new Error("result.scenario.id is not supported");
- }
- for (const key of SCENARIO_KEYS.slice(1)) {
- assertMetric(value.scenario[key], `result.scenario.${key}`);
- }
-
- assertExactKeys(value.providers, PROVIDERS, "result.providers");
- for (const provider of PROVIDERS) {
- const entry = value.providers[provider];
- assertExactKeys(entry, ["observed", "ceiling"], `result.providers.${provider}`);
- assertExactKeys(entry.observed, OBSERVED_KEYS, `result.providers.${provider}.observed`);
- assertExactKeys(entry.ceiling, CEILING_KEYS, `result.providers.${provider}.ceiling`);
- for (const key of OBSERVED_KEYS) {
- assertMetric(entry.observed[key], `result.providers.${provider}.observed.${key}`);
- }
- for (const key of CEILING_KEYS) {
- assertMetric(entry.ceiling[key], `result.providers.${provider}.ceiling.${key}`);
- }
- }
-
- return value;
-}
-
-function readResult(directory) {
- if (!directory) return undefined;
- const file = path.join(directory, RESULT_FILE);
- if (!fs.existsSync(file)) return undefined;
- const stat = fs.lstatSync(file);
- if (!stat.isFile() || stat.size > 64 * 1_024) {
- throw new Error("thread transfer result must be a regular file smaller than 64 KiB");
- }
- return validateResult(JSON.parse(fs.readFileSync(file, "utf8")));
-}
-
-function formatBytes(bytes) {
- if (bytes < 1_024) return `${bytes} B`;
- if (bytes >= 1_024 * 1_024) return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB`;
- return `${(bytes / 1_024).toFixed(1)} KiB`;
-}
-
-function formatValue(value, kind) {
- return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value);
-}
-
-function formatImpact(current, baseline, kind) {
- if (baseline === undefined) return "—";
- const delta = current - baseline;
- const prefix = delta > 0 ? "+" : delta < 0 ? "−" : "";
- const magnitude = formatValue(Math.abs(delta), kind);
- const percent =
- baseline === 0 ? "" : ` (${prefix}${Math.abs((delta / baseline) * 100).toFixed(1)}%)`;
- return `${prefix}${magnitude}${percent}`;
-}
-
-function sameScenario(left, right) {
- return SCENARIO_KEYS.every((key) => left[key] === right[key]);
-}
-
-const METRICS = [
- { key: "totalWireBytes", label: "Total thread wire", kind: "bytes" },
- { key: "threadSnapshotWireBytes", label: "Thread snapshot wire", kind: "bytes" },
- {
- key: "measuredTurnWebSocketWireBytes",
- label: "Live turn WebSocket wire",
- kind: "bytes",
- },
- {
- key: "measuredTurnWebSocketDecodedBytes",
- label: "Live turn WebSocket decoded",
- kind: "bytes",
- },
- { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" },
-];
-
-function renderComment(input) {
- const current = input.current;
- const baseline = input.baseline;
- const comparable = baseline !== undefined && sameScenario(current.scenario, baseline.scenario);
- const rows = [];
- const ceilingChanges = [];
- let failed = false;
-
- for (const provider of PROVIDERS) {
- for (const metric of METRICS) {
- const observed = current.providers[provider].observed[metric.key];
- const ceiling = current.providers[provider].ceiling[metric.key];
- const baselineObserved = comparable
- ? baseline.providers[provider].observed[metric.key]
- : undefined;
- const pass = observed <= ceiling;
- failed ||= !pass;
- rows.push(
- `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`,
- );
-
- if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) {
- ceilingChanges.push(
- `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`,
- );
- }
- }
- }
-
- const baselineLink = input.baselineRun
- ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})`
- : "unavailable";
- const currentLink = `[\`${input.currentRun.sha.slice(0, 7)}\`](${input.currentRun.url})`;
- const notices = [];
- if (!baseline) {
- notices.push(
- "> ℹ️ No successful `main` baseline artifact is available yet. This run establishes the initial measurement.",
- );
- } else if (!comparable) {
- notices.push(
- "> ⚠️ The thread fixture changed, so impact percentages are not directly comparable to the `main` baseline.",
- );
- } else if (!input.baselineRun.matchesBase) {
- notices.push(
- "> ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful `main` measurement shown below.",
- );
- }
- if (ceilingChanges.length > 0) {
- notices.push(
- `> ⚠️ **This PR changes transfer ceilings:**\n>\n${ceilingChanges.map((line) => `> ${line}`).join("\n")}`,
- );
- }
-
- return [
- COMMENT_MARKER,
- resultShaMarker(input.currentRun.sha),
- "## Thread transfer impact",
- "",
- failed
- ? "❌ One or more thread transfer ceilings were exceeded."
- : "✅ Thread transfer remains within every enforced ceiling.",
- ...(notices.length > 0 ? ["", ...notices] : []),
- "",
- "| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |",
- "| --- | --- | ---: | ---: | ---: | ---: | --- |",
- ...rows,
- "",
- `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`,
- "",
- "",
- "Scenario and decoded snapshot size
",
- "",
- `${current.scenario.historyTurns} historical turns, ${current.scenario.historyCommandToolsPerTurn} command tools per turn, ${formatBytes(current.scenario.historyMcpResultBytes)} retained MCP result per historical turn, and a ${formatBytes(current.scenario.measuredMcpResultBytes)} retained result in the measured turn.`,
- "",
- ...PROVIDERS.map(
- (provider) =>
- `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`,
- ),
- "",
- " ",
- "",
- "_Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed._",
- ].join("\n");
-}
-
-async function artifactsForRun(github, owner, repo, runId) {
- return github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
- owner,
- repo,
- run_id: runId,
- per_page: 100,
- });
-}
-
-function findResultArtifact(artifacts) {
- return artifacts.find((artifact) => artifact.name === ARTIFACT_NAME && !artifact.expired);
-}
-
-async function resolve({ github, context, core }) {
- const source = context.payload.workflow_run;
- const { owner, repo } = context.repo;
- if (source.event !== "pull_request") {
- core.setOutput("publish", "false");
- return;
- }
-
- let pullNumber = source.pull_requests?.[0]?.number;
- if (!pullNumber) {
- const associated = await github.paginate(
- github.rest.repos.listPullRequestsAssociatedWithCommit,
- { owner, repo, commit_sha: source.head_sha, per_page: 100 },
- );
- const matchingPulls = associated.filter(
- (pull) =>
- pull.state === "open" &&
- pull.head.sha === source.head_sha &&
- pull.head.ref === source.head_branch,
- );
- if (matchingPulls.length !== 1) {
- core.info(
- `Expected one open pull request for ${source.head_repository?.full_name ?? "unknown repository"}:${source.head_branch ?? "unknown branch"} at ${source.head_sha}; found ${matchingPulls.length}.`,
- );
- core.setOutput("publish", "false");
- return;
- }
- pullNumber = matchingPulls[0].number;
- }
- if (!pullNumber) {
- core.info("No open pull request is associated with the completed CI run.");
- core.setOutput("publish", "false");
- return;
- }
-
- const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber });
- if (pull.head.sha !== source.head_sha) {
- core.info(`Skipping stale CI result ${source.head_sha}; PR head is ${pull.head.sha}.`);
- core.setOutput("publish", "false");
- return;
- }
-
- const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id);
- const sourceArtifact = findResultArtifact(sourceArtifacts);
- const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
- owner,
- repo,
- workflow_id: source.workflow_id,
- branch: pull.base.ref,
- event: "push",
- status: "success",
- per_page: 100,
- });
- const orderedRuns = [
- ...workflowRuns.filter((run) => run.head_sha === pull.base.sha),
- ...workflowRuns.filter((run) => run.head_sha !== pull.base.sha),
- ].slice(0, 20);
-
- let baselineRun;
- for (const run of orderedRuns) {
- const artifacts = await artifactsForRun(github, owner, repo, run.id);
- if (findResultArtifact(artifacts)) {
- baselineRun = run;
- break;
- }
- }
-
- core.setOutput("publish", "true");
- core.setOutput("pull_number", String(pullNumber));
- core.setOutput("pr_artifact", sourceArtifact ? "true" : "false");
- core.setOutput("pr_run_id", String(source.id));
- core.setOutput("pr_sha", source.head_sha);
- core.setOutput("pr_conclusion", source.conclusion ?? "unknown");
- core.setOutput("baseline_artifact", baselineRun ? "true" : "false");
- core.setOutput("baseline_run_id", baselineRun ? String(baselineRun.id) : "");
- core.setOutput("baseline_sha", baselineRun?.head_sha ?? "");
- core.setOutput(
- "baseline_matches_base",
- baselineRun?.head_sha === pull.base.sha ? "true" : "false",
- );
-}
-
-async function upsertComment(github, context, pullNumber, body, options = {}) {
- const { owner, repo } = context.repo;
- const comments = await github.paginate(github.rest.issues.listComments, {
- owner,
- repo,
- issue_number: pullNumber,
- per_page: 100,
- });
- const existing = comments.find(
- (comment) =>
- comment.user?.login === "github-actions[bot]" && comment.body?.includes(COMMENT_MARKER),
- );
- if (
- options.preserveResultSha &&
- existing?.body?.includes(resultShaMarker(options.preserveResultSha))
- ) {
- return;
- }
- if (existing) {
- await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
- } else {
- await github.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body });
- }
-}
-
-async function upsertCommentForCurrentHead(
- github,
- context,
- core,
- pullNumber,
- expectedSha,
- body,
- options,
-) {
- const { owner, repo } = context.repo;
- const { data: pull } = await github.rest.pulls.get({
- owner,
- repo,
- pull_number: pullNumber,
- });
- if (pull.head.sha !== expectedSha) {
- core.info(`Skipping stale CI result ${expectedSha}; PR head is ${pull.head.sha}.`);
- return false;
- }
-
- await upsertComment(github, context, pullNumber, body, options);
- return true;
-}
-
-async function publish({ github, context, core }) {
- const pullNumber = Number(process.env.PR_NUMBER);
- if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) {
- throw new Error("PR_NUMBER is invalid");
- }
-
- const current = readResult(process.env.PR_RESULT_DIR);
- const currentRun = {
- sha: process.env.PR_SHA,
- conclusion: process.env.PR_CONCLUSION,
- url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.PR_RUN_ID}`,
- };
- if (!current) {
- await upsertCommentForCurrentHead(
- github,
- context,
- core,
- pullNumber,
- currentRun.sha,
- [
- COMMENT_MARKER,
- "## Thread transfer impact",
- "",
- `⚠️ The latest [CI run](${currentRun.url}) did not produce a thread transfer result for \`${currentRun.sha.slice(0, 7)}\`.`,
- "",
- "_This comment will update automatically after the next completed run._",
- ].join("\n"),
- { preserveResultSha: currentRun.sha },
- );
- return;
- }
-
- const baseline = readResult(process.env.BASELINE_RESULT_DIR);
- const baselineRun = baseline
- ? {
- sha: process.env.BASELINE_SHA,
- matchesBase: process.env.BASELINE_MATCHES_BASE === "true",
- url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`,
- }
- : undefined;
- const body = renderComment({ current, baseline, currentRun, baselineRun });
- const published = await upsertCommentForCurrentHead(
- github,
- context,
- core,
- pullNumber,
- currentRun.sha,
- body,
- );
- if (published) {
- core.info(`Updated thread transfer report on PR #${pullNumber}.`);
- }
-}
-
-module.exports = {
- publish,
- readResult,
- renderComment,
- resolve,
- upsertCommentForCurrentHead,
- validateResult,
-};
diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs
deleted file mode 100644
index 4935864e46f0..000000000000
--- a/.github/scripts/thread-transfer-report.test.cjs
+++ /dev/null
@@ -1,292 +0,0 @@
-const assert = require("node:assert/strict");
-const test = require("node:test");
-
-const {
- renderComment,
- resolve,
- upsertCommentForCurrentHead,
- validateResult,
-} = require("./thread-transfer-report.cjs");
-
-function result(overrides = {}) {
- const observed = {
- totalWireBytes: 2_200_000,
- threadSnapshotWireBytes: 1_950_000,
- threadSnapshotDecodedBytes: 9_100_000,
- measuredTurnWebSocketWireBytes: 250_000,
- measuredTurnWebSocketDecodedBytes: 1_150_000,
- measuredTurnWebSocketMessages: 15,
- };
- const ceiling = {
- totalWireBytes: 2_900_000,
- threadSnapshotWireBytes: 2_600_000,
- measuredTurnWebSocketWireBytes: 320_000,
- measuredTurnWebSocketDecodedBytes: 1_550_000,
- measuredTurnWebSocketMessages: 20,
- };
- return {
- schemaVersion: 1,
- scenario: {
- id: "thread-transfer-v1",
- historyTurns: 10,
- historyCommandToolsPerTurn: 5,
- historyMcpResultBytes: 900_000,
- measuredCommandTools: 20,
- measuredMcpResultBytes: 1_100_000,
- },
- providers: {
- codex: { observed: { ...observed, ...overrides }, ceiling },
- claudeAgent: { observed, ceiling },
- },
- };
-}
-
-test("validates the fixed artifact schema", () => {
- assert.equal(validateResult(result()).schemaVersion, 1);
- assert.throws(
- () => validateResult({ ...result(), injectedMarkdown: "@everyone" }),
- /unexpected fields/,
- );
- assert.throws(
- () => validateResult(result({ totalWireBytes: "lots" })),
- /non-negative safe integer/,
- );
-});
-
-test("renders baseline, impact, ceiling, and ceiling changes", () => {
- const baseline = result();
- const current = result({ measuredTurnWebSocketWireBytes: 260_000 });
- current.providers.codex.ceiling = {
- ...current.providers.codex.ceiling,
- measuredTurnWebSocketWireBytes: 330_000,
- };
- const comment = renderComment({
- current,
- baseline,
- currentRun: {
- sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
- conclusion: "success",
- url: "https://github.com/pingdotgg/t3code/actions/runs/2",
- },
- baselineRun: {
- sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
- matchesBase: true,
- url: "https://github.com/pingdotgg/t3code/actions/runs/1",
- },
- });
-
- assert.match(comment, /Main baseline \| This PR \| Impact \| PR ceiling/);
- assert.match(comment, /\+9\.8 KiB \(\+4\.0%\)/);
- assert.match(comment, /This PR changes transfer ceilings/);
- assert.match(comment, /312\.5 KiB → 322\.3 KiB/);
- assert.match(comment, //);
- assert.match(
- comment,
- //,
- );
-});
-
-test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => {
- const outputs = {};
- const listWorkflowRunArtifacts = () => {};
- const listWorkflowRuns = () => {};
- const listPullRequestsAssociatedWithCommit = () => {};
- const github = {
- paginate: async (method, input) => {
- if (method === listPullRequestsAssociatedWithCommit) {
- return [
- {
- number: 5350,
- state: "open",
- head: { sha: "head-sha", ref: "feature-branch", repo: null },
- },
- ];
- }
- if (method === listWorkflowRunArtifacts) {
- return [
- {
- name: "thread-transfer-results",
- expired: false,
- runId: input.run_id,
- },
- ];
- }
- if (method === listWorkflowRuns) {
- return [{ id: 1, head_sha: "base-sha" }];
- }
- throw new Error("unexpected pagination call");
- },
- rest: {
- actions: { listWorkflowRunArtifacts, listWorkflowRuns },
- pulls: {
- get: async () => ({
- data: {
- head: { sha: "head-sha" },
- base: { sha: "base-sha", ref: "main" },
- },
- }),
- },
- repos: { listPullRequestsAssociatedWithCommit },
- },
- };
- await resolve({
- github,
- context: {
- repo: { owner: "pingdotgg", repo: "t3code" },
- payload: {
- workflow_run: {
- id: 2,
- event: "pull_request",
- workflow_id: 3,
- head_sha: "head-sha",
- head_branch: "feature-branch",
- head_repository: { full_name: "pingdotgg/t3code" },
- conclusion: "success",
- pull_requests: [],
- },
- },
- },
- core: {
- info: () => {},
- setOutput: (key, value) => {
- outputs[key] = value;
- },
- },
- });
-
- assert.equal(outputs.publish, "true");
- assert.equal(outputs.pull_number, "5350");
- assert.equal(outputs.pr_artifact, "true");
- assert.equal(outputs.baseline_run_id, "1");
- assert.equal(outputs.baseline_matches_base, "true");
-});
-
-test("does not guess when a fallback commit belongs to multiple PRs", async () => {
- const outputs = {};
- const listPullRequestsAssociatedWithCommit = () => {};
- let fetchedPull = false;
- await resolve({
- github: {
- paginate: async (method) => {
- assert.equal(method, listPullRequestsAssociatedWithCommit);
- return [5350, 5351].map((number) => ({
- number,
- state: "open",
- head: {
- sha: "head-sha",
- ref: "feature-branch",
- repo: { full_name: "pingdotgg/t3code" },
- },
- }));
- },
- rest: {
- actions: {},
- pulls: {
- get: async () => {
- fetchedPull = true;
- },
- },
- repos: { listPullRequestsAssociatedWithCommit },
- },
- },
- context: {
- repo: { owner: "pingdotgg", repo: "t3code" },
- payload: {
- workflow_run: {
- id: 2,
- event: "pull_request",
- workflow_id: 3,
- head_sha: "head-sha",
- head_branch: "feature-branch",
- head_repository: { full_name: "pingdotgg/t3code" },
- conclusion: "success",
- pull_requests: [],
- },
- },
- },
- core: {
- info: () => {},
- setOutput: (key, value) => {
- outputs[key] = value;
- },
- },
- });
-
- assert.equal(outputs.publish, "false");
- assert.equal(fetchedPull, false);
-});
-
-test("does not publish a stale result after the PR head advances", async () => {
- let listedComments = false;
- const info = [];
- const published = await upsertCommentForCurrentHead(
- {
- paginate: async () => {
- listedComments = true;
- return [];
- },
- rest: {
- issues: {
- listComments: () => {},
- createComment: () => {
- throw new Error("must not create a stale comment");
- },
- updateComment: () => {
- throw new Error("must not update a stale comment");
- },
- },
- pulls: {
- get: async () => ({ data: { head: { sha: "new-head-sha" } } }),
- },
- },
- },
- { repo: { owner: "pingdotgg", repo: "t3code" } },
- { info: (message) => info.push(message) },
- 5350,
- "old-head-sha",
- "stale body",
- );
-
- assert.equal(published, false);
- assert.equal(listedComments, false);
- assert.deepEqual(info, ["Skipping stale CI result old-head-sha; PR head is new-head-sha."]);
-});
-
-test("preserves a successful result when a same-SHA rerun has no artifact", async () => {
- let updatedComment = false;
- const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
- const published = await upsertCommentForCurrentHead(
- {
- paginate: async () => [
- {
- id: 1,
- user: { login: "github-actions[bot]" },
- body: `\n`,
- },
- ],
- rest: {
- issues: {
- listComments: () => {},
- createComment: () => {
- updatedComment = true;
- },
- updateComment: () => {
- updatedComment = true;
- },
- },
- pulls: {
- get: async () => ({ data: { head: { sha } } }),
- },
- },
- },
- { repo: { owner: "pingdotgg", repo: "t3code" } },
- { info: () => {} },
- 5350,
- sha,
- "missing artifact warning",
- { preserveResultSha: sha },
- );
-
- assert.equal(published, true);
- assert.equal(updatedComment, false);
-});
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 19a28f205d86..ea7a3c76b183 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -86,6 +86,16 @@ jobs:
!/.repos/
sparse-checkout-cone-mode: false
+ # Blacksmith boots GitHub's Ubuntu runner image (gcc is usually present),
+ # but ACP process-tree live tests compile a small pthread fixture with `cc`
+ # and soft-skip when it is missing. Install build-essential so that path
+ # always runs in CI instead of silently no-oping.
+ - name: Install C toolchain for process-tree fixtures
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends build-essential
+ command -v cc
+
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml
deleted file mode 100644
index 23eec72923bd..000000000000
--- a/.github/workflows/thread-transfer-report.yml
+++ /dev/null
@@ -1,75 +0,0 @@
-name: Thread Transfer Report
-
-on:
- workflow_run:
- workflows: [CI]
- types: [completed]
-
-permissions:
- actions: read
- contents: read
- pull-requests: write
-
-jobs:
- publish:
- name: Publish PR comment
- if: github.event.workflow_run.event == 'pull_request'
- runs-on: ubuntu-24.04
- concurrency:
- group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }}
- cancel-in-progress: true
- steps:
- # workflow_run has a write-capable token even for fork PRs. Only load the
- # publisher from the trusted default branch and never execute PR code.
- - name: Checkout trusted publisher
- uses: actions/checkout@v6
- with:
- ref: ${{ github.event.repository.default_branch }}
- sparse-checkout: .github/scripts
-
- - name: Test trusted publisher
- run: node --test .github/scripts/thread-transfer-report.test.cjs
-
- - id: resolve
- name: Resolve PR and baseline artifacts
- uses: actions/github-script@v8
- with:
- script: |
- const reporter = require("./.github/scripts/thread-transfer-report.cjs");
- await reporter.resolve({ github, context, core });
-
- - name: Download PR result
- if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true'
- uses: actions/download-artifact@v8
- with:
- name: thread-transfer-results
- path: ${{ runner.temp }}/thread-transfer/pr
- github-token: ${{ secrets.GITHUB_TOKEN }}
- run-id: ${{ steps.resolve.outputs.pr_run_id }}
-
- - name: Download main baseline
- if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true'
- uses: actions/download-artifact@v8
- with:
- name: thread-transfer-results
- path: ${{ runner.temp }}/thread-transfer/main
- github-token: ${{ secrets.GITHUB_TOKEN }}
- run-id: ${{ steps.resolve.outputs.baseline_run_id }}
-
- - name: Update thread transfer comment
- if: steps.resolve.outputs.publish == 'true'
- uses: actions/github-script@v8
- env:
- PR_NUMBER: ${{ steps.resolve.outputs.pull_number }}
- PR_SHA: ${{ steps.resolve.outputs.pr_sha }}
- PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }}
- PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }}
- PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr
- BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }}
- BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }}
- BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }}
- BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main
- with:
- script: |
- const reporter = require("./.github/scripts/thread-transfer-report.cjs");
- await reporter.publish({ github, context, core });
diff --git a/.gitignore b/.gitignore
index 8482c5a290e1..38bac103f1f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,7 @@ squashfs-root/
.vercel
.gstack/
.plans/
+/audits/
dist-electron/
.electron-runtime/
.showcase/
diff --git a/README.md b/README.md
index 27b5dc491693..fbb2a2c96574 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [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)
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts
index 262097ca78ea..87b0e7c15554 100644
--- a/apps/desktop/src/app/DesktopEnvironment.test.ts
+++ b/apps/desktop/src/app/DesktopEnvironment.test.ts
@@ -102,6 +102,8 @@ describe("DesktopEnvironment", () => {
assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
+ assert.equal(environment.userDataDirName, "t3code");
+ assert.equal(environment.legacyUserDataDirName, "T3 Code (Alpha)");
}),
);
diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts
index 5a5a20780a65..1ec0ebf1874d 100644
--- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts
+++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts
@@ -121,7 +121,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
input.readMagicDnsName ??
readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
- Effect.orElseSucceed(() => null),
+ Effect.orElseSucceed((): string | null => null),
);
const dnsName =
input.statusJson === undefined
diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 53044dcf7e5d..d45a1015e029 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -56,6 +56,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
proactivePanelsEnabled: true,
showSkillsInSlashMenu: false,
+ persistComposerContextStrip: true,
providerModelPreferences: {},
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
diff --git a/apps/marketing/public/app-desktop.webp b/apps/marketing/public/app-desktop.webp
new file mode 100644
index 000000000000..11b51331eef3
Binary files /dev/null and b/apps/marketing/public/app-desktop.webp differ
diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro
index 669bdce8a72d..e3f5e3b6bd12 100644
--- a/apps/marketing/src/pages/index.astro
+++ b/apps/marketing/src/pages/index.astro
@@ -236,7 +236,7 @@ const mobileEndorsementRows = [

diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css
index d63fe16d578b..6e9c07a52ee8 100644
--- a/apps/mobile/generated-uniwind-themes.css
+++ b/apps/mobile/generated-uniwind-themes.css
@@ -5,6 +5,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -15,6 +17,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -38,6 +41,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -50,6 +55,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -65,6 +72,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -75,6 +84,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -98,6 +108,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -110,6 +122,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -200,6 +214,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -210,6 +226,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -233,6 +250,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -245,6 +264,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -335,6 +356,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -345,6 +368,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -368,6 +392,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -380,6 +406,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -470,6 +498,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -480,6 +510,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -503,6 +534,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -515,6 +548,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -605,6 +640,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -615,6 +652,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -638,6 +676,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -650,6 +690,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -740,6 +782,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -750,6 +794,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -773,6 +818,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -785,6 +832,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -875,6 +924,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -885,6 +936,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -908,6 +960,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -920,6 +974,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -1010,6 +1066,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -1020,6 +1078,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -1043,6 +1102,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -1055,6 +1116,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -1145,6 +1208,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -1155,6 +1220,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -1178,6 +1244,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -1190,6 +1258,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -1280,6 +1350,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -1290,6 +1362,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -1313,6 +1386,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -1325,6 +1400,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -1415,6 +1492,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -1425,6 +1504,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -1448,6 +1528,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -1460,6 +1542,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 18fb67319176..f8cf1c462dcb 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -95,7 +95,7 @@
"expo-image-picker": "~57.0.14",
"expo-linking": "~57.0.8",
"expo-network": "~57.0.1",
- "expo-notifications": "~57.0.15",
+ "expo-notifications": "57.0.15",
"expo-paste-input": "^0.1.15",
"expo-quick-actions": "^6.0.2",
"expo-secure-store": "~57.0.2",
diff --git a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift
new file mode 100644
index 000000000000..6d13972d5742
--- /dev/null
+++ b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift
@@ -0,0 +1,189 @@
+import Foundation
+
+// The registry runs on macOS without starting a simulator. Only the OS-facing
+// types are replaced; the tests compile the dependency's actual Swift source.
+public enum UIBackgroundFetchResult {
+ case noData
+}
+
+public struct UNNotificationPresentationOptions: OptionSet {
+ public let rawValue: Int
+ public init(rawValue: Int) { self.rawValue = rawValue }
+}
+
+public final class UNNotification: NSObject {}
+
+public final class UNNotificationResponse: NSObject {
+ let identifier: String
+ init(_ identifier: String) { self.identifier = identifier }
+}
+
+public protocol UNUserNotificationCenterDelegate: AnyObject {}
+
+public final class UNUserNotificationCenter: NSObject {
+ private static let instance = UNUserNotificationCenter()
+ public weak var delegate: UNUserNotificationCenterDelegate?
+ public static func current() -> UNUserNotificationCenter { instance }
+}
+
+private final class TestDelegate: NotificationDelegate {
+ private let lock = NSLock()
+ private var recordedEvents: [String] = []
+ var onEvent: ((String) -> Void)?
+ var onResponse: ((UNNotificationResponse) -> Bool)?
+
+ var events: [String] { lock.withLock { recordedEvents } }
+
+ private func record(_ event: String) {
+ lock.withLock { recordedEvents.append(event) }
+ onEvent?(event)
+ }
+
+ func didRegister(_ deviceToken: String) { record("registered") }
+ func didFailRegistration(_ error: Error) { record("failed") }
+ func openSettings(_ notification: UNNotification?) { record("settings") }
+
+ func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool {
+ record("present")
+ return false
+ }
+
+ func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool {
+ record("background")
+ return false
+ }
+
+ func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool {
+ record(response.identifier)
+ return onResponse?(response) ?? false
+ }
+}
+
+private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
+ if !condition() {
+ throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
+ }
+}
+
+private func deliver(_ identifier: String) {
+ NotificationCenterManager.shared.userNotificationCenter(
+ UNUserNotificationCenter.current(),
+ didReceive: UNNotificationResponse(identifier),
+ withCompletionHandler: {}
+ )
+}
+
+private func reentrantCallbacks() throws {
+ let manager = NotificationCenterManager.shared
+ let center = UNUserNotificationCenter.current()
+ let callbacks: [(String, () -> Void)] = [
+ ("registered", { manager.didRegister("token") }),
+ ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }),
+ ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }),
+ ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }),
+ ("background", { manager.didReceive([:], completionHandler: { _ in }) })
+ ]
+
+ for (event, callback) in callbacks {
+ let original = TestDelegate()
+ let replacement = TestDelegate()
+ original.onEvent = { [weak original] _ in
+ guard let original else { return }
+ manager.removeDelegate(original)
+ manager.addDelegate(replacement)
+ }
+ manager.addDelegate(original)
+ callback()
+ try require(original.events == [event], "The original delegate must receive the callback once")
+ try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot")
+ callback()
+ try require(replacement.events == [event], "Reentrant registration must take effect for the next callback")
+ manager.removeDelegate(replacement)
+ }
+}
+
+private func registrationDuringDelivery() throws {
+ let manager = NotificationCenterManager.shared
+ let original = TestDelegate()
+ let receiver = TestDelegate()
+ receiver.onResponse = { _ in true }
+ original.onResponse = { _ in
+ manager.addDelegate(receiver)
+ return false
+ }
+ manager.addDelegate(original)
+ deliver("handoff")
+ try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response")
+ manager.removeDelegate(original)
+ manager.removeDelegate(receiver)
+}
+
+private func responseDuringReplay() throws {
+ let manager = NotificationCenterManager.shared
+ deliver("first")
+ let original = TestDelegate()
+ original.onResponse = { response in
+ if response.identifier == "first" {
+ deliver("second")
+ return true
+ }
+ return false
+ }
+ manager.addDelegate(original)
+ let receiver = TestDelegate()
+ receiver.onResponse = { _ in true }
+ manager.addDelegate(receiver)
+ try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks")
+ manager.removeDelegate(original)
+ manager.removeDelegate(receiver)
+}
+
+private func concurrentRegistrations() throws {
+ let manager = NotificationCenterManager.shared
+ let delegates = (0..<512).map { _ in
+ let delegate = TestDelegate()
+ delegate.onResponse = { _ in true }
+ return delegate
+ }
+ DispatchQueue.concurrentPerform(iterations: delegates.count) { index in
+ manager.addDelegate(delegates[index])
+ if index.isMultiple(of: 16) {
+ manager.didRegister("during-add")
+ deliver("during-add")
+ }
+ }
+ let before = delegates.map { $0.events.count }
+ manager.didRegister("after-add")
+ for (index, delegate) in delegates.enumerated() {
+ try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once")
+ }
+ DispatchQueue.concurrentPerform(iterations: delegates.count) { index in
+ manager.removeDelegate(delegates[index])
+ if index.isMultiple(of: 16) {
+ manager.didRegister("during-remove")
+ deliver("during-remove")
+ }
+ }
+ let removed = delegates.map { $0.events.count }
+ manager.didRegister("after-remove")
+ try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks")
+}
+
+@main
+private enum RegressionTests {
+ static func main() {
+ do {
+ switch CommandLine.arguments.last {
+ case "reentrant": try reentrantCallbacks()
+ case "handoff": try registrationDuringDelivery()
+ case "pending": try responseDuringReplay()
+ case "concurrent": try concurrentRegistrations()
+ default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2)
+ }
+ print("passed")
+ } catch {
+ FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
+ exit(1)
+ }
+ }
+}
diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts
index 6878dc0ee785..92636df85a7a 100644
--- a/apps/mobile/scripts/generate-uniwind-themes.mts
+++ b/apps/mobile/scripts/generate-uniwind-themes.mts
@@ -57,6 +57,8 @@ const ADAPTIVE_COLORS: Readonly
{
+ let directory: string;
+ let executable: string;
+
+ beforeAll(() => {
+ directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-notifications-test-"));
+ executable = NodePath.join(directory, "notification-regression");
+ const source = NodeFS.readFileSync(
+ new URL(
+ "../node_modules/expo-notifications/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift",
+ import.meta.url,
+ ),
+ "utf8",
+ );
+ const manager = NodePath.join(directory, "NotificationCenterManager.swift");
+ NodeFS.writeFileSync(
+ manager,
+ source.replace(/^import (ExpoModulesCore|UserNotifications)\n/gm, ""),
+ );
+ NodeChildProcess.execFileSync(
+ "swiftc",
+ [
+ "-swift-version",
+ "5",
+ "-sanitize=thread",
+ manager,
+ NodeURL.fileURLToPath(
+ new URL("./fixtures/NotificationCenterManagerRegression.swift", import.meta.url),
+ ),
+ "-o",
+ executable,
+ ],
+ { timeout: 30_000, encoding: "utf8" },
+ );
+ });
+
+ afterAll(() => {
+ if (directory) NodeFS.rmSync(directory, { recursive: true, force: true });
+ });
+
+ it.each([
+ ["reentrant", "allows callbacks to replace delegates without deadlocking"],
+ ["handoff", "delivers responses to delegates registering during delivery"],
+ ["pending", "retains new responses received while replaying pending responses"],
+ ["concurrent", "registers, removes, and broadcasts concurrently without data races"],
+ ])("%s: %s", (name) => {
+ const output = NodeChildProcess.execFileSync(executable, [name], {
+ encoding: "utf8",
+ timeout: 15_000,
+ });
+ expect(output.trim()).toBe("passed");
+ });
+ },
+);
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 049cd2888962..c24aa2a97d53 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -31,6 +31,7 @@ import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
+import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl";
import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen";
@@ -376,6 +377,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
"NewTaskSheet",
"SettingsLegal",
"SettingsSheet",
+ "ThreadQueue",
"ThreadReviewComment",
"ThreadSettingsSheet",
]);
@@ -566,6 +568,15 @@ export const RootStack = createNativeStackNavigator({
}),
},
}),
+ ThreadQueue: createNativeStackScreen({
+ screen: ThreadQueueSheet,
+ options: {
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
+ headerShown: false,
+ sheetAllowedDetents: [0.65, 0.95],
+ sheetGrabberVisible: true,
+ },
+ }),
GitOverview: createNativeStackScreen({
screen: GitOverviewSheet,
linking: `${THREAD_LINKING_PREFIX}/git`,
diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx
index c8f1d4385517..1643f3f6fdeb 100644
--- a/apps/mobile/src/components/BrandMark.tsx
+++ b/apps/mobile/src/components/BrandMark.tsx
@@ -3,14 +3,9 @@ import { Image } from "expo-image";
import { View } from "react-native";
import { AppText as Text } from "./AppText";
+import { T3_CODE_BRAND_MARK_SOURCE } from "./brandAssets";
const appVariant = Constants.expoConfig?.extra?.appVariant;
-const BRAND_MARK_SOURCE =
- appVariant === "development"
- ? require("../../../../assets/dev/blueprint-ios-1024.png")
- : appVariant === "preview"
- ? require("../../../../assets/nightly/nightly-ios-1024.png")
- : require("../../../../assets/prod/black-ios-1024.png");
const DEFAULT_STAGE_LABEL =
appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha";
@@ -22,7 +17,7 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab
return (
+
+
+ );
+}
+
+function AcpRegistryProviderIcon(props: {
+ readonly color: string;
+ readonly iconUrl: string | null | undefined;
+ readonly size: number;
+}) {
+ const iconUrl = resolveOfficialAcpRegistryIconUrl(props.iconUrl);
+ const [image, setImage] = useState<{
+ readonly iconUrl: string;
+ readonly status: "loaded" | "failed";
+ } | null>(null);
+ const currentImage = image?.iconUrl === iconUrl ? image : null;
+ const loaded = currentImage?.status === "loaded";
+
+ return (
+
+ {!loaded ? : null}
+ {iconUrl !== null && currentImage?.status !== "failed" ? (
+ setImage({ iconUrl, status: "failed" })}
+ onLoad={() => setImage({ iconUrl, status: "loaded" })}
+ />
+ ) : null}
+
+ );
+}
+
export function ProviderIcon(props: ProviderIconProps) {
const { themeAppearance } = useAppearancePreferences();
const isDarkMode = themeAppearance === "dark";
@@ -25,6 +77,9 @@ export function ProviderIcon(props: ProviderIconProps) {
/>
);
}
+ if (props.provider === "acpRegistry") {
+ return ;
+ }
if (props.provider === "claudeAgent") {
return (
@@ -64,6 +119,20 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}
+ if (props.provider === "pi") {
+ const foreground = isDarkMode ? "#F5F5F5" : "#0F0F0F";
+ return (
+
+ );
+ }
+
if (props.provider === "opencode") {
return (