Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion .agents/upstream-review.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
---
remote: t3code-upstream
branch: main
reviewed-through: "f035a0f4cdf4abaa6704673af7b5a4a321149ba2"
reviewed-through: "bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c"
reviewed-through-date: "2026-08-24"
---

Expand DownExpand Up@@ -2425,6 +2425,65 @@ Not done, and deliberately: no real-client pass in web or mobile. `#8009` redesi
and `$` menus, `#8048` changes composer attachment UI, and `#8058` changes approval prompts on
both clients; all three warrant a look in a running client before release.

## 2026-08-25 — `f035a0f4cdf4abaa6704673af7b5a4a321149ba2..bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c`

Twelve upstream commits grouped into ten change sets. The developer approved the full batch. Nine
sets produced source changes on `upstream/2026-08-25-bd9ed2-batch`; A6 was already present in
Pylon, so its cherry-pick was correctly empty. The deferred register was empty going in and remains
empty.

Pylon-first reconciliation was required in three places. A5's projection optimization now preserves
full shell-summary refreshes for Pylon's `interaction.requested`, `interaction.resolved`, and
`provider.interaction.respond.failed` activities, with a request/resolution regression test. A8
keeps Pylon's application ids, protocols, product/artifact names, local ad-hoc signing, and optional
passkey setup while enabling upstream's batched signed-build hook. A9's upstream migration 042 was
renumbered to **046** because Pylon already owns 042 through 045; using 042 would silently skip the
linked-PR column on existing Pylon databases.

| ID | Upstream | Decision | Pylon reference | Notes |
| --- | ------------------------------------------- | ------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `5f1147cad` / `#8124` | adopted | `0875df643` | Nightly-to-nightly skew compares the full date/run while stable/nightly compatibility remains core-version based. Pylon wording retained. |
| A2 | `8287f2c3a`, `a1379db81` / `#8111`, `#8108` | adopted | `adc19dbec`, `f4608a35c` | Usage skeleton matches the loaded layout and model rows follow the selected cost/token metric. Pylon's active-provider rationale retained. |
| A3 | `883e1a3cd` / `#7488` | adopted | `79b4bec99` | Terminal links show hover affordance only with the activation modifier; uppercase URL schemes also activate correctly. |
| A4 | `a09f92171`, `99960383d` / `#8081`, `#8098` | adopted | `2b7f2e6be`, `f7e8c7522` | Windows, spaced, HTML, and XML agent file links reach the integrated viewer on the applicable web/mobile surfaces. Unsafe schemes remain rejected. |
| A5 | `c034f51bb` / `#8150` | adopted with adaptation | `c21a6c05b`, `0f99d52af` | Routine assistant/tool events stop rescanning full histories. Pylon interaction activities remain summary-changing. |
| A6 | `2394998aa` / `#8163` | adopted (already present) | `877dad32a` | Both `@xmldom/xmldom` deprecation records already existed; the upstream cherry-pick was empty and skipped. |
| A7 | `143341b0b` / `#8089` | adopted | `92bbc7ccf` | Configurable `mod+shift+s` settles or restores the active thread and stays inactive in the terminal. |
| A8 | `63eb0429f` / `#8093` | adopted with adaptation | `d6a20af51` | Batches macOS codesign calls, trims staged dependencies, and excludes Windows terminal binaries without crossing Pylon's desktop identity boundaries. |
| A9 | `3c75eb113` / `#8160` | adopted with adaptation | `ddc4cfb50` | Persisted PR-to-thread linking across server, contracts, web, and mobile. Migration renumbered 042 → 046. |
| A10 | `bd9ed2b4b` / `#8161` | adopted | `21a40af90` | Web/desktop HEIC and HEIF attachments convert to guarded JPEG through a lazy `heic-to` decoder. |

Focused validation before ledger commit: 516 changed-behavior tests pass (285 web, 73 server, 87
contracts/client-runtime, 56 mobile, 15 version-skew), plus the macOS signing test. All seven
affected package typechecks pass. Lint reports 0 errors and two pre-existing `prefer-set-has`
warnings in an untouched part of `ProjectionSnapshotQuery.test.ts`; formatting is clean over all 70
changed source/doc/config files. `build-desktop-artifact.test.ts` has one pre-existing host-dependent
failure on Apple Silicon: its alleged cross-architecture fixture is arm64, so the native probe runs;
the same assertion fails on clean `pylon`.

No real-client pass ran during integration. Web/mobile UI verification still requires explicit
computer-use permission.

### Post-integration review

The final rebased branch received a separate source-level review after integration. A fresh fetch
confirmed that upstream `main` still ends at `bd9ed2b4`, so no commit appeared after the original
review. Patch coverage includes every upstream-touched file; the only path differences are the two
A9 migration files deliberately renamed from 042 to 046. A zero-context added-line scan found no T3
product name, app id, renderer protocol, artifact name, or hosted-origin drift. The migration
manifest is unique and ordered through 046. The Pylon-specific Prime activity kinds used to derive
pending input are a subset of the optimized refresh allowlist. Contracts, capability gating,
server projection and persistence, web, mobile, reverse actions, connection modes, and user docs
were each checked with no confirmed cross-surface omission.

A production web build also passed. It confirms that `heic-to/csp` resolves and remains a separate
lazy chunk, so HEIC support does not increase the initial bundle. Two risks remain visible for
maintainer review: that decoder chunk is 2.995 MB minified / 751 KB gzip on first use, and
`heic-to@1.5.2` declares LGPL-3.0 while Pylon has no artifact-level third-party notice flow. The
known Apple Silicon packaging-test baseline failure remains unrelated. Real-client screenshots and
manual interaction checks were not run because browser/computer-use permission was requested but
not granted.

## Deferred register

_The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const RELATIVE_FILE_PATH_PATTERN =
/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN =
/^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const POSIX_FILE_ROOT_PREFIXES = [
"/Users/",
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import {
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import type {
EnvironmentId,
SidebarProjectGroupingMode,
Expand DownExpand Up@@ -54,6 +53,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "../threads/threadListV2";
import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
Expand DownExpand Up@@ -491,15 +491,16 @@ export function HomeScreen(props: HomeScreenProps) {
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule, matching web.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
Expand DownExpand Up@@ -84,6 +83,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "./threadListV2";

Expand DownExpand Up@@ -377,15 +377,16 @@ function ThreadNavigationSidebarPane(
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
23 changes: 12 additions & 11 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,7 @@ import type {
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import {
canSnooze,
resolveSnoozePresets,
type ChangeRequestSettleSource,
} from "@t3tools/client-runtime/state/thread-settled";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
Expand All@@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
resolveThreadListV2ChangeRequestState,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2Status,
resolveThreadListV2SwipeActions,
type ThreadListV2ChangeRequestState,
type ThreadListV2Status,
} from "./threadListV2";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
Expand DownExpand Up@@ -377,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
merge and close rules. Mirrors web's onChangeRequestState. */
readonly onChangeRequestState?: (
threadKey: string,
changeRequest: ChangeRequestSettleSource | null,
changeRequest: ThreadListV2ChangeRequestState | null,
) => void;
readonly projectCwd?: string | null;
readonly searchMatch?: EnvironmentThreadSearchMatch;
Expand DownExpand Up@@ -411,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const prUpdatedAt = pr?.updatedAt ?? null;
const threadKey = `${thread.environmentId}:${thread.id}`;
useEffect(() => {
onChangeRequestState?.(
threadKey,
prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
);
}, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
const changeRequest = resolveThreadListV2ChangeRequestState({
linkedPullRequest: thread.linkedPullRequest,
state: prState,
updatedAt: prUpdatedAt,
});
if (changeRequest === undefined) return;
onChangeRequestState?.(threadKey, changeRequest);
}, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);

const screenColor = useThemeColor("--color-screen");
const drawerColor = useThemeColor("--color-drawer");
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveThreadListV2ChangeRequestState,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -53,6 +54,48 @@ function makeThread(
}

const NOW = "2026-06-02T00:00:00.000Z";
const linkedPullRequest = {
projectId: ProjectId.make("project-1"),
repository: "pingdotgg/t3code",
number: 42,
url: "https://github.com/pingdotgg/t3code/pull/42",
};

describe("resolveThreadListV2ChangeRequestState", () => {
it("preserves the previous state while a linked pull request reloads", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: null,
updatedAt: null,
}),
).toBeUndefined();
});

it("clears the previous state after a pull request is unlinked", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest: null,
state: null,
updatedAt: null,
}),
).toBeNull();
});

it("reports a loaded linked pull request", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
}),
).toEqual({
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
});
});
});

describe("resolveThreadListV2SnoozeMenuSelection", () => {
it("accepts a displayed evening preset while its wake time is still future", () => {
Expand DownExpand Up@@ -263,6 +306,58 @@ describe("sortThreadsForListV2", () => {
});

describe("buildThreadListV2Items", () => {
it("ignores the previous pull request state after a different pull request is linked", () => {
const thread = makeThread({
id: ThreadId.make("linked"),
title: "Linked pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(0);
expect(layout.items[0]?.variant).toBe("card");
});

it("settles a thread only when the cached pull request identity matches", () => {
const thread = makeThread({
id: ThreadId.make("linked-merged"),
title: "Linked merged pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(1);
expect(layout.items[0]?.variant).toBe("slim");
});

it("keeps a merged thread active when auto-settle on merge is off", () => {
const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
const layout = buildThreadListV2Items({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion .agents/upstream-review.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
---
remote: t3code-upstream
branch: main
reviewed-through: "f035a0f4cdf4abaa6704673af7b5a4a321149ba2"
reviewed-through: "bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c"
reviewed-through-date: "2026-08-24"
---

Expand DownExpand Up@@ -2425,6 +2425,65 @@ Not done, and deliberately: no real-client pass in web or mobile. `#8009` redesi
and `$` menus, `#8048` changes composer attachment UI, and `#8058` changes approval prompts on
both clients; all three warrant a look in a running client before release.

## 2026-08-25 — `f035a0f4cdf4abaa6704673af7b5a4a321149ba2..bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c`

Twelve upstream commits grouped into ten change sets. The developer approved the full batch. Nine
sets produced source changes on `upstream/2026-08-25-bd9ed2-batch`; A6 was already present in
Pylon, so its cherry-pick was correctly empty. The deferred register was empty going in and remains
empty.

Pylon-first reconciliation was required in three places. A5's projection optimization now preserves
full shell-summary refreshes for Pylon's `interaction.requested`, `interaction.resolved`, and
`provider.interaction.respond.failed` activities, with a request/resolution regression test. A8
keeps Pylon's application ids, protocols, product/artifact names, local ad-hoc signing, and optional
passkey setup while enabling upstream's batched signed-build hook. A9's upstream migration 042 was
renumbered to **046** because Pylon already owns 042 through 045; using 042 would silently skip the
linked-PR column on existing Pylon databases.

| ID | Upstream | Decision | Pylon reference | Notes |
| --- | ------------------------------------------- | ------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `5f1147cad` / `#8124` | adopted | `0875df643` | Nightly-to-nightly skew compares the full date/run while stable/nightly compatibility remains core-version based. Pylon wording retained. |
| A2 | `8287f2c3a`, `a1379db81` / `#8111`, `#8108` | adopted | `adc19dbec`, `f4608a35c` | Usage skeleton matches the loaded layout and model rows follow the selected cost/token metric. Pylon's active-provider rationale retained. |
| A3 | `883e1a3cd` / `#7488` | adopted | `79b4bec99` | Terminal links show hover affordance only with the activation modifier; uppercase URL schemes also activate correctly. |
| A4 | `a09f92171`, `99960383d` / `#8081`, `#8098` | adopted | `2b7f2e6be`, `f7e8c7522` | Windows, spaced, HTML, and XML agent file links reach the integrated viewer on the applicable web/mobile surfaces. Unsafe schemes remain rejected. |
| A5 | `c034f51bb` / `#8150` | adopted with adaptation | `c21a6c05b`, `0f99d52af` | Routine assistant/tool events stop rescanning full histories. Pylon interaction activities remain summary-changing. |
| A6 | `2394998aa` / `#8163` | adopted (already present) | `877dad32a` | Both `@xmldom/xmldom` deprecation records already existed; the upstream cherry-pick was empty and skipped. |
| A7 | `143341b0b` / `#8089` | adopted | `92bbc7ccf` | Configurable `mod+shift+s` settles or restores the active thread and stays inactive in the terminal. |
| A8 | `63eb0429f` / `#8093` | adopted with adaptation | `d6a20af51` | Batches macOS codesign calls, trims staged dependencies, and excludes Windows terminal binaries without crossing Pylon's desktop identity boundaries. |
| A9 | `3c75eb113` / `#8160` | adopted with adaptation | `ddc4cfb50` | Persisted PR-to-thread linking across server, contracts, web, and mobile. Migration renumbered 042 → 046. |
| A10 | `bd9ed2b4b` / `#8161` | adopted | `21a40af90` | Web/desktop HEIC and HEIF attachments convert to guarded JPEG through a lazy `heic-to` decoder. |

Focused validation before ledger commit: 516 changed-behavior tests pass (285 web, 73 server, 87
contracts/client-runtime, 56 mobile, 15 version-skew), plus the macOS signing test. All seven
affected package typechecks pass. Lint reports 0 errors and two pre-existing `prefer-set-has`
warnings in an untouched part of `ProjectionSnapshotQuery.test.ts`; formatting is clean over all 70
changed source/doc/config files. `build-desktop-artifact.test.ts` has one pre-existing host-dependent
failure on Apple Silicon: its alleged cross-architecture fixture is arm64, so the native probe runs;
the same assertion fails on clean `pylon`.

No real-client pass ran during integration. Web/mobile UI verification still requires explicit
computer-use permission.

### Post-integration review

The final rebased branch received a separate source-level review after integration. A fresh fetch
confirmed that upstream `main` still ends at `bd9ed2b4`, so no commit appeared after the original
review. Patch coverage includes every upstream-touched file; the only path differences are the two
A9 migration files deliberately renamed from 042 to 046. A zero-context added-line scan found no T3
product name, app id, renderer protocol, artifact name, or hosted-origin drift. The migration
manifest is unique and ordered through 046. The Pylon-specific Prime activity kinds used to derive
pending input are a subset of the optimized refresh allowlist. Contracts, capability gating,
server projection and persistence, web, mobile, reverse actions, connection modes, and user docs
were each checked with no confirmed cross-surface omission.

A production web build also passed. It confirms that `heic-to/csp` resolves and remains a separate
lazy chunk, so HEIC support does not increase the initial bundle. Two risks remain visible for
maintainer review: that decoder chunk is 2.995 MB minified / 751 KB gzip on first use, and
`heic-to@1.5.2` declares LGPL-3.0 while Pylon has no artifact-level third-party notice flow. The
known Apple Silicon packaging-test baseline failure remains unrelated. Real-client screenshots and
manual interaction checks were not run because browser/computer-use permission was requested but
not granted.

## Deferred register

_The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const RELATIVE_FILE_PATH_PATTERN =
/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN =
/^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const POSIX_FILE_ROOT_PREFIXES = [
"/Users/",
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import {
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import type {
EnvironmentId,
SidebarProjectGroupingMode,
Expand DownExpand Up@@ -54,6 +53,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "../threads/threadListV2";
import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
Expand DownExpand Up@@ -491,15 +491,16 @@ export function HomeScreen(props: HomeScreenProps) {
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule, matching web.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
Expand DownExpand Up@@ -84,6 +83,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "./threadListV2";

Expand DownExpand Up@@ -377,15 +377,16 @@ function ThreadNavigationSidebarPane(
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
23 changes: 12 additions & 11 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,7 @@ import type {
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import {
canSnooze,
resolveSnoozePresets,
type ChangeRequestSettleSource,
} from "@t3tools/client-runtime/state/thread-settled";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
Expand All@@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
resolveThreadListV2ChangeRequestState,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2Status,
resolveThreadListV2SwipeActions,
type ThreadListV2ChangeRequestState,
type ThreadListV2Status,
} from "./threadListV2";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
Expand DownExpand Up@@ -377,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
merge and close rules. Mirrors web's onChangeRequestState. */
readonly onChangeRequestState?: (
threadKey: string,
changeRequest: ChangeRequestSettleSource | null,
changeRequest: ThreadListV2ChangeRequestState | null,
) => void;
readonly projectCwd?: string | null;
readonly searchMatch?: EnvironmentThreadSearchMatch;
Expand DownExpand Up@@ -411,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const prUpdatedAt = pr?.updatedAt ?? null;
const threadKey = `${thread.environmentId}:${thread.id}`;
useEffect(() => {
onChangeRequestState?.(
threadKey,
prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
);
}, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
const changeRequest = resolveThreadListV2ChangeRequestState({
linkedPullRequest: thread.linkedPullRequest,
state: prState,
updatedAt: prUpdatedAt,
});
if (changeRequest === undefined) return;
onChangeRequestState?.(threadKey, changeRequest);
}, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);

const screenColor = useThemeColor("--color-screen");
const drawerColor = useThemeColor("--color-drawer");
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveThreadListV2ChangeRequestState,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -53,6 +54,48 @@ function makeThread(
}

const NOW = "2026-06-02T00:00:00.000Z";
const linkedPullRequest = {
projectId: ProjectId.make("project-1"),
repository: "pingdotgg/t3code",
number: 42,
url: "https://github.com/pingdotgg/t3code/pull/42",
};

describe("resolveThreadListV2ChangeRequestState", () => {
it("preserves the previous state while a linked pull request reloads", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: null,
updatedAt: null,
}),
).toBeUndefined();
});

it("clears the previous state after a pull request is unlinked", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest: null,
state: null,
updatedAt: null,
}),
).toBeNull();
});

it("reports a loaded linked pull request", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
}),
).toEqual({
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
});
});
});

describe("resolveThreadListV2SnoozeMenuSelection", () => {
it("accepts a displayed evening preset while its wake time is still future", () => {
Expand DownExpand Up@@ -263,6 +306,58 @@ describe("sortThreadsForListV2", () => {
});

describe("buildThreadListV2Items", () => {
it("ignores the previous pull request state after a different pull request is linked", () => {
const thread = makeThread({
id: ThreadId.make("linked"),
title: "Linked pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(0);
expect(layout.items[0]?.variant).toBe("card");
});

it("settles a thread only when the cached pull request identity matches", () => {
const thread = makeThread({
id: ThreadId.make("linked-merged"),
title: "Linked merged pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(1);
expect(layout.items[0]?.variant).toBe("slim");
});

it("keeps a merged thread active when auto-settle on merge is off", () => {
const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
const layout = buildThreadListV2Items({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion .agents/upstream-review.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
---
remote: t3code-upstream
branch: main
reviewed-through: "f035a0f4cdf4abaa6704673af7b5a4a321149ba2"
reviewed-through: "bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c"
reviewed-through-date: "2026-08-24"
---

Expand DownExpand Up@@ -2425,6 +2425,65 @@ Not done, and deliberately: no real-client pass in web or mobile. `#8009` redesi
and `$` menus, `#8048` changes composer attachment UI, and `#8058` changes approval prompts on
both clients; all three warrant a look in a running client before release.

## 2026-08-25 — `f035a0f4cdf4abaa6704673af7b5a4a321149ba2..bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c`

Twelve upstream commits grouped into ten change sets. The developer approved the full batch. Nine
sets produced source changes on `upstream/2026-08-25-bd9ed2-batch`; A6 was already present in
Pylon, so its cherry-pick was correctly empty. The deferred register was empty going in and remains
empty.

Pylon-first reconciliation was required in three places. A5's projection optimization now preserves
full shell-summary refreshes for Pylon's `interaction.requested`, `interaction.resolved`, and
`provider.interaction.respond.failed` activities, with a request/resolution regression test. A8
keeps Pylon's application ids, protocols, product/artifact names, local ad-hoc signing, and optional
passkey setup while enabling upstream's batched signed-build hook. A9's upstream migration 042 was
renumbered to **046** because Pylon already owns 042 through 045; using 042 would silently skip the
linked-PR column on existing Pylon databases.

| ID | Upstream | Decision | Pylon reference | Notes |
| --- | ------------------------------------------- | ------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `5f1147cad` / `#8124` | adopted | `0875df643` | Nightly-to-nightly skew compares the full date/run while stable/nightly compatibility remains core-version based. Pylon wording retained. |
| A2 | `8287f2c3a`, `a1379db81` / `#8111`, `#8108` | adopted | `adc19dbec`, `f4608a35c` | Usage skeleton matches the loaded layout and model rows follow the selected cost/token metric. Pylon's active-provider rationale retained. |
| A3 | `883e1a3cd` / `#7488` | adopted | `79b4bec99` | Terminal links show hover affordance only with the activation modifier; uppercase URL schemes also activate correctly. |
| A4 | `a09f92171`, `99960383d` / `#8081`, `#8098` | adopted | `2b7f2e6be`, `f7e8c7522` | Windows, spaced, HTML, and XML agent file links reach the integrated viewer on the applicable web/mobile surfaces. Unsafe schemes remain rejected. |
| A5 | `c034f51bb` / `#8150` | adopted with adaptation | `c21a6c05b`, `0f99d52af` | Routine assistant/tool events stop rescanning full histories. Pylon interaction activities remain summary-changing. |
| A6 | `2394998aa` / `#8163` | adopted (already present) | `877dad32a` | Both `@xmldom/xmldom` deprecation records already existed; the upstream cherry-pick was empty and skipped. |
| A7 | `143341b0b` / `#8089` | adopted | `92bbc7ccf` | Configurable `mod+shift+s` settles or restores the active thread and stays inactive in the terminal. |
| A8 | `63eb0429f` / `#8093` | adopted with adaptation | `d6a20af51` | Batches macOS codesign calls, trims staged dependencies, and excludes Windows terminal binaries without crossing Pylon's desktop identity boundaries. |
| A9 | `3c75eb113` / `#8160` | adopted with adaptation | `ddc4cfb50` | Persisted PR-to-thread linking across server, contracts, web, and mobile. Migration renumbered 042 → 046. |
| A10 | `bd9ed2b4b` / `#8161` | adopted | `21a40af90` | Web/desktop HEIC and HEIF attachments convert to guarded JPEG through a lazy `heic-to` decoder. |

Focused validation before ledger commit: 516 changed-behavior tests pass (285 web, 73 server, 87
contracts/client-runtime, 56 mobile, 15 version-skew), plus the macOS signing test. All seven
affected package typechecks pass. Lint reports 0 errors and two pre-existing `prefer-set-has`
warnings in an untouched part of `ProjectionSnapshotQuery.test.ts`; formatting is clean over all 70
changed source/doc/config files. `build-desktop-artifact.test.ts` has one pre-existing host-dependent
failure on Apple Silicon: its alleged cross-architecture fixture is arm64, so the native probe runs;
the same assertion fails on clean `pylon`.

No real-client pass ran during integration. Web/mobile UI verification still requires explicit
computer-use permission.

### Post-integration review

The final rebased branch received a separate source-level review after integration. A fresh fetch
confirmed that upstream `main` still ends at `bd9ed2b4`, so no commit appeared after the original
review. Patch coverage includes every upstream-touched file; the only path differences are the two
A9 migration files deliberately renamed from 042 to 046. A zero-context added-line scan found no T3
product name, app id, renderer protocol, artifact name, or hosted-origin drift. The migration
manifest is unique and ordered through 046. The Pylon-specific Prime activity kinds used to derive
pending input are a subset of the optimized refresh allowlist. Contracts, capability gating,
server projection and persistence, web, mobile, reverse actions, connection modes, and user docs
were each checked with no confirmed cross-surface omission.

A production web build also passed. It confirms that `heic-to/csp` resolves and remains a separate
lazy chunk, so HEIC support does not increase the initial bundle. Two risks remain visible for
maintainer review: that decoder chunk is 2.995 MB minified / 751 KB gzip on first use, and
`heic-to@1.5.2` declares LGPL-3.0 while Pylon has no artifact-level third-party notice flow. The
known Apple Silicon packaging-test baseline failure remains unrelated. Real-client screenshots and
manual interaction checks were not run because browser/computer-use permission was requested but
not granted.

## Deferred register

_The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const RELATIVE_FILE_PATH_PATTERN =
/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN =
/^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const POSIX_FILE_ROOT_PREFIXES = [
"/Users/",
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import {
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import type {
EnvironmentId,
SidebarProjectGroupingMode,
Expand DownExpand Up@@ -54,6 +53,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "../threads/threadListV2";
import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
Expand DownExpand Up@@ -491,15 +491,16 @@ export function HomeScreen(props: HomeScreenProps) {
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule, matching web.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
Expand DownExpand Up@@ -84,6 +83,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "./threadListV2";

Expand DownExpand Up@@ -377,15 +377,16 @@ function ThreadNavigationSidebarPane(
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
23 changes: 12 additions & 11 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,7 @@ import type {
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import {
canSnooze,
resolveSnoozePresets,
type ChangeRequestSettleSource,
} from "@t3tools/client-runtime/state/thread-settled";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
Expand All@@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
resolveThreadListV2ChangeRequestState,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2Status,
resolveThreadListV2SwipeActions,
type ThreadListV2ChangeRequestState,
type ThreadListV2Status,
} from "./threadListV2";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
Expand DownExpand Up@@ -377,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
merge and close rules. Mirrors web's onChangeRequestState. */
readonly onChangeRequestState?: (
threadKey: string,
changeRequest: ChangeRequestSettleSource | null,
changeRequest: ThreadListV2ChangeRequestState | null,
) => void;
readonly projectCwd?: string | null;
readonly searchMatch?: EnvironmentThreadSearchMatch;
Expand DownExpand Up@@ -411,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const prUpdatedAt = pr?.updatedAt ?? null;
const threadKey = `${thread.environmentId}:${thread.id}`;
useEffect(() => {
onChangeRequestState?.(
threadKey,
prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
);
}, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
const changeRequest = resolveThreadListV2ChangeRequestState({
linkedPullRequest: thread.linkedPullRequest,
state: prState,
updatedAt: prUpdatedAt,
});
if (changeRequest === undefined) return;
onChangeRequestState?.(threadKey, changeRequest);
}, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);

const screenColor = useThemeColor("--color-screen");
const drawerColor = useThemeColor("--color-drawer");
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveThreadListV2ChangeRequestState,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -53,6 +54,48 @@ function makeThread(
}

const NOW = "2026-06-02T00:00:00.000Z";
const linkedPullRequest = {
projectId: ProjectId.make("project-1"),
repository: "pingdotgg/t3code",
number: 42,
url: "https://github.com/pingdotgg/t3code/pull/42",
};

describe("resolveThreadListV2ChangeRequestState", () => {
it("preserves the previous state while a linked pull request reloads", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: null,
updatedAt: null,
}),
).toBeUndefined();
});

it("clears the previous state after a pull request is unlinked", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest: null,
state: null,
updatedAt: null,
}),
).toBeNull();
});

it("reports a loaded linked pull request", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
}),
).toEqual({
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
});
});
});

describe("resolveThreadListV2SnoozeMenuSelection", () => {
it("accepts a displayed evening preset while its wake time is still future", () => {
Expand DownExpand Up@@ -263,6 +306,58 @@ describe("sortThreadsForListV2", () => {
});

describe("buildThreadListV2Items", () => {
it("ignores the previous pull request state after a different pull request is linked", () => {
const thread = makeThread({
id: ThreadId.make("linked"),
title: "Linked pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(0);
expect(layout.items[0]?.variant).toBe("card");
});

it("settles a thread only when the cached pull request identity matches", () => {
const thread = makeThread({
id: ThreadId.make("linked-merged"),
title: "Linked merged pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(1);
expect(layout.items[0]?.variant).toBe("slim");
});

it("keeps a merged thread active when auto-settle on merge is off", () => {
const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
const layout = buildThreadListV2Items({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion .agents/upstream-review.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
---
remote: t3code-upstream
branch: main
reviewed-through: "f035a0f4cdf4abaa6704673af7b5a4a321149ba2"
reviewed-through: "bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c"
reviewed-through-date: "2026-08-24"
---

Expand DownExpand Up@@ -2425,6 +2425,65 @@ Not done, and deliberately: no real-client pass in web or mobile. `#8009` redesi
and `$` menus, `#8048` changes composer attachment UI, and `#8058` changes approval prompts on
both clients; all three warrant a look in a running client before release.

## 2026-08-25 — `f035a0f4cdf4abaa6704673af7b5a4a321149ba2..bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c`

Twelve upstream commits grouped into ten change sets. The developer approved the full batch. Nine
sets produced source changes on `upstream/2026-08-25-bd9ed2-batch`; A6 was already present in
Pylon, so its cherry-pick was correctly empty. The deferred register was empty going in and remains
empty.

Pylon-first reconciliation was required in three places. A5's projection optimization now preserves
full shell-summary refreshes for Pylon's `interaction.requested`, `interaction.resolved`, and
`provider.interaction.respond.failed` activities, with a request/resolution regression test. A8
keeps Pylon's application ids, protocols, product/artifact names, local ad-hoc signing, and optional
passkey setup while enabling upstream's batched signed-build hook. A9's upstream migration 042 was
renumbered to **046** because Pylon already owns 042 through 045; using 042 would silently skip the
linked-PR column on existing Pylon databases.

| ID | Upstream | Decision | Pylon reference | Notes |
| --- | ------------------------------------------- | ------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `5f1147cad` / `#8124` | adopted | `0875df643` | Nightly-to-nightly skew compares the full date/run while stable/nightly compatibility remains core-version based. Pylon wording retained. |
| A2 | `8287f2c3a`, `a1379db81` / `#8111`, `#8108` | adopted | `adc19dbec`, `f4608a35c` | Usage skeleton matches the loaded layout and model rows follow the selected cost/token metric. Pylon's active-provider rationale retained. |
| A3 | `883e1a3cd` / `#7488` | adopted | `79b4bec99` | Terminal links show hover affordance only with the activation modifier; uppercase URL schemes also activate correctly. |
| A4 | `a09f92171`, `99960383d` / `#8081`, `#8098` | adopted | `2b7f2e6be`, `f7e8c7522` | Windows, spaced, HTML, and XML agent file links reach the integrated viewer on the applicable web/mobile surfaces. Unsafe schemes remain rejected. |
| A5 | `c034f51bb` / `#8150` | adopted with adaptation | `c21a6c05b`, `0f99d52af` | Routine assistant/tool events stop rescanning full histories. Pylon interaction activities remain summary-changing. |
| A6 | `2394998aa` / `#8163` | adopted (already present) | `877dad32a` | Both `@xmldom/xmldom` deprecation records already existed; the upstream cherry-pick was empty and skipped. |
| A7 | `143341b0b` / `#8089` | adopted | `92bbc7ccf` | Configurable `mod+shift+s` settles or restores the active thread and stays inactive in the terminal. |
| A8 | `63eb0429f` / `#8093` | adopted with adaptation | `d6a20af51` | Batches macOS codesign calls, trims staged dependencies, and excludes Windows terminal binaries without crossing Pylon's desktop identity boundaries. |
| A9 | `3c75eb113` / `#8160` | adopted with adaptation | `ddc4cfb50` | Persisted PR-to-thread linking across server, contracts, web, and mobile. Migration renumbered 042 → 046. |
| A10 | `bd9ed2b4b` / `#8161` | adopted | `21a40af90` | Web/desktop HEIC and HEIF attachments convert to guarded JPEG through a lazy `heic-to` decoder. |

Focused validation before ledger commit: 516 changed-behavior tests pass (285 web, 73 server, 87
contracts/client-runtime, 56 mobile, 15 version-skew), plus the macOS signing test. All seven
affected package typechecks pass. Lint reports 0 errors and two pre-existing `prefer-set-has`
warnings in an untouched part of `ProjectionSnapshotQuery.test.ts`; formatting is clean over all 70
changed source/doc/config files. `build-desktop-artifact.test.ts` has one pre-existing host-dependent
failure on Apple Silicon: its alleged cross-architecture fixture is arm64, so the native probe runs;
the same assertion fails on clean `pylon`.

No real-client pass ran during integration. Web/mobile UI verification still requires explicit
computer-use permission.

### Post-integration review

The final rebased branch received a separate source-level review after integration. A fresh fetch
confirmed that upstream `main` still ends at `bd9ed2b4`, so no commit appeared after the original
review. Patch coverage includes every upstream-touched file; the only path differences are the two
A9 migration files deliberately renamed from 042 to 046. A zero-context added-line scan found no T3
product name, app id, renderer protocol, artifact name, or hosted-origin drift. The migration
manifest is unique and ordered through 046. The Pylon-specific Prime activity kinds used to derive
pending input are a subset of the optimized refresh allowlist. Contracts, capability gating,
server projection and persistence, web, mobile, reverse actions, connection modes, and user docs
were each checked with no confirmed cross-surface omission.

A production web build also passed. It confirms that `heic-to/csp` resolves and remains a separate
lazy chunk, so HEIC support does not increase the initial bundle. Two risks remain visible for
maintainer review: that decoder chunk is 2.995 MB minified / 751 KB gzip on first use, and
`heic-to@1.5.2` declares LGPL-3.0 while Pylon has no artifact-level third-party notice flow. The
known Apple Silicon packaging-test baseline failure remains unrelated. Real-client screenshots and
manual interaction checks were not run because browser/computer-use permission was requested but
not granted.

## Deferred register

_The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const RELATIVE_FILE_PATH_PATTERN =
/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN =
/^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const POSIX_FILE_ROOT_PREFIXES = [
"/Users/",
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import {
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import type {
EnvironmentId,
SidebarProjectGroupingMode,
Expand DownExpand Up@@ -54,6 +53,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "../threads/threadListV2";
import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
Expand DownExpand Up@@ -491,15 +491,16 @@ export function HomeScreen(props: HomeScreenProps) {
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule, matching web.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
Expand DownExpand Up@@ -84,6 +83,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "./threadListV2";

Expand DownExpand Up@@ -377,15 +377,16 @@ function ThreadNavigationSidebarPane(
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
23 changes: 12 additions & 11 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,7 @@ import type {
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import {
canSnooze,
resolveSnoozePresets,
type ChangeRequestSettleSource,
} from "@t3tools/client-runtime/state/thread-settled";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
Expand All@@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
resolveThreadListV2ChangeRequestState,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2Status,
resolveThreadListV2SwipeActions,
type ThreadListV2ChangeRequestState,
type ThreadListV2Status,
} from "./threadListV2";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
Expand DownExpand Up@@ -377,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
merge and close rules. Mirrors web's onChangeRequestState. */
readonly onChangeRequestState?: (
threadKey: string,
changeRequest: ChangeRequestSettleSource | null,
changeRequest: ThreadListV2ChangeRequestState | null,
) => void;
readonly projectCwd?: string | null;
readonly searchMatch?: EnvironmentThreadSearchMatch;
Expand DownExpand Up@@ -411,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const prUpdatedAt = pr?.updatedAt ?? null;
const threadKey = `${thread.environmentId}:${thread.id}`;
useEffect(() => {
onChangeRequestState?.(
threadKey,
prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
);
}, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
const changeRequest = resolveThreadListV2ChangeRequestState({
linkedPullRequest: thread.linkedPullRequest,
state: prState,
updatedAt: prUpdatedAt,
});
if (changeRequest === undefined) return;
onChangeRequestState?.(threadKey, changeRequest);
}, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);

const screenColor = useThemeColor("--color-screen");
const drawerColor = useThemeColor("--color-drawer");
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveThreadListV2ChangeRequestState,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -53,6 +54,48 @@ function makeThread(
}

const NOW = "2026-06-02T00:00:00.000Z";
const linkedPullRequest = {
projectId: ProjectId.make("project-1"),
repository: "pingdotgg/t3code",
number: 42,
url: "https://github.com/pingdotgg/t3code/pull/42",
};

describe("resolveThreadListV2ChangeRequestState", () => {
it("preserves the previous state while a linked pull request reloads", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: null,
updatedAt: null,
}),
).toBeUndefined();
});

it("clears the previous state after a pull request is unlinked", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest: null,
state: null,
updatedAt: null,
}),
).toBeNull();
});

it("reports a loaded linked pull request", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
}),
).toEqual({
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
});
});
});

describe("resolveThreadListV2SnoozeMenuSelection", () => {
it("accepts a displayed evening preset while its wake time is still future", () => {
Expand DownExpand Up@@ -263,6 +306,58 @@ describe("sortThreadsForListV2", () => {
});

describe("buildThreadListV2Items", () => {
it("ignores the previous pull request state after a different pull request is linked", () => {
const thread = makeThread({
id: ThreadId.make("linked"),
title: "Linked pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(0);
expect(layout.items[0]?.variant).toBe("card");
});

it("settles a thread only when the cached pull request identity matches", () => {
const thread = makeThread({
id: ThreadId.make("linked-merged"),
title: "Linked merged pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(1);
expect(layout.items[0]?.variant).toBe("slim");
});

it("keeps a merged thread active when auto-settle on merge is off", () => {
const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
const layout = buildThreadListV2Items({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion .agents/upstream-review.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
---
remote: t3code-upstream
branch: main
reviewed-through: "f035a0f4cdf4abaa6704673af7b5a4a321149ba2"
reviewed-through: "bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c"
reviewed-through-date: "2026-08-24"
---

Expand DownExpand Up@@ -2425,6 +2425,65 @@ Not done, and deliberately: no real-client pass in web or mobile. `#8009` redesi
and `$` menus, `#8048` changes composer attachment UI, and `#8058` changes approval prompts on
both clients; all three warrant a look in a running client before release.

## 2026-08-25 — `f035a0f4cdf4abaa6704673af7b5a4a321149ba2..bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c`

Twelve upstream commits grouped into ten change sets. The developer approved the full batch. Nine
sets produced source changes on `upstream/2026-08-25-bd9ed2-batch`; A6 was already present in
Pylon, so its cherry-pick was correctly empty. The deferred register was empty going in and remains
empty.

Pylon-first reconciliation was required in three places. A5's projection optimization now preserves
full shell-summary refreshes for Pylon's `interaction.requested`, `interaction.resolved`, and
`provider.interaction.respond.failed` activities, with a request/resolution regression test. A8
keeps Pylon's application ids, protocols, product/artifact names, local ad-hoc signing, and optional
passkey setup while enabling upstream's batched signed-build hook. A9's upstream migration 042 was
renumbered to **046** because Pylon already owns 042 through 045; using 042 would silently skip the
linked-PR column on existing Pylon databases.

| ID | Upstream | Decision | Pylon reference | Notes |
| --- | ------------------------------------------- | ------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `5f1147cad` / `#8124` | adopted | `0875df643` | Nightly-to-nightly skew compares the full date/run while stable/nightly compatibility remains core-version based. Pylon wording retained. |
| A2 | `8287f2c3a`, `a1379db81` / `#8111`, `#8108` | adopted | `adc19dbec`, `f4608a35c` | Usage skeleton matches the loaded layout and model rows follow the selected cost/token metric. Pylon's active-provider rationale retained. |
| A3 | `883e1a3cd` / `#7488` | adopted | `79b4bec99` | Terminal links show hover affordance only with the activation modifier; uppercase URL schemes also activate correctly. |
| A4 | `a09f92171`, `99960383d` / `#8081`, `#8098` | adopted | `2b7f2e6be`, `f7e8c7522` | Windows, spaced, HTML, and XML agent file links reach the integrated viewer on the applicable web/mobile surfaces. Unsafe schemes remain rejected. |
| A5 | `c034f51bb` / `#8150` | adopted with adaptation | `c21a6c05b`, `0f99d52af` | Routine assistant/tool events stop rescanning full histories. Pylon interaction activities remain summary-changing. |
| A6 | `2394998aa` / `#8163` | adopted (already present) | `877dad32a` | Both `@xmldom/xmldom` deprecation records already existed; the upstream cherry-pick was empty and skipped. |
| A7 | `143341b0b` / `#8089` | adopted | `92bbc7ccf` | Configurable `mod+shift+s` settles or restores the active thread and stays inactive in the terminal. |
| A8 | `63eb0429f` / `#8093` | adopted with adaptation | `d6a20af51` | Batches macOS codesign calls, trims staged dependencies, and excludes Windows terminal binaries without crossing Pylon's desktop identity boundaries. |
| A9 | `3c75eb113` / `#8160` | adopted with adaptation | `ddc4cfb50` | Persisted PR-to-thread linking across server, contracts, web, and mobile. Migration renumbered 042 → 046. |
| A10 | `bd9ed2b4b` / `#8161` | adopted | `21a40af90` | Web/desktop HEIC and HEIF attachments convert to guarded JPEG through a lazy `heic-to` decoder. |

Focused validation before ledger commit: 516 changed-behavior tests pass (285 web, 73 server, 87
contracts/client-runtime, 56 mobile, 15 version-skew), plus the macOS signing test. All seven
affected package typechecks pass. Lint reports 0 errors and two pre-existing `prefer-set-has`
warnings in an untouched part of `ProjectionSnapshotQuery.test.ts`; formatting is clean over all 70
changed source/doc/config files. `build-desktop-artifact.test.ts` has one pre-existing host-dependent
failure on Apple Silicon: its alleged cross-architecture fixture is arm64, so the native probe runs;
the same assertion fails on clean `pylon`.

No real-client pass ran during integration. Web/mobile UI verification still requires explicit
computer-use permission.

### Post-integration review

The final rebased branch received a separate source-level review after integration. A fresh fetch
confirmed that upstream `main` still ends at `bd9ed2b4`, so no commit appeared after the original
review. Patch coverage includes every upstream-touched file; the only path differences are the two
A9 migration files deliberately renamed from 042 to 046. A zero-context added-line scan found no T3
product name, app id, renderer protocol, artifact name, or hosted-origin drift. The migration
manifest is unique and ordered through 046. The Pylon-specific Prime activity kinds used to derive
pending input are a subset of the optimized refresh allowlist. Contracts, capability gating,
server projection and persistence, web, mobile, reverse actions, connection modes, and user docs
were each checked with no confirmed cross-surface omission.

A production web build also passed. It confirms that `heic-to/csp` resolves and remains a separate
lazy chunk, so HEIC support does not increase the initial bundle. Two risks remain visible for
maintainer review: that decoder chunk is 2.995 MB minified / 751 KB gzip on first use, and
`heic-to@1.5.2` declares LGPL-3.0 while Pylon has no artifact-level third-party notice flow. The
known Apple Silicon packaging-test baseline failure remains unrelated. Real-client screenshots and
manual interaction checks were not run because browser/computer-use permission was requested but
not granted.

## Deferred register

_The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const RELATIVE_FILE_PATH_PATTERN =
/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN =
/^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const POSIX_FILE_ROOT_PREFIXES = [
"/Users/",
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import {
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import type {
EnvironmentId,
SidebarProjectGroupingMode,
Expand DownExpand Up@@ -54,6 +53,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "../threads/threadListV2";
import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
Expand DownExpand Up@@ -491,15 +491,16 @@ export function HomeScreen(props: HomeScreenProps) {
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule, matching web.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
Expand DownExpand Up@@ -84,6 +83,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "./threadListV2";

Expand DownExpand Up@@ -377,15 +377,16 @@ function ThreadNavigationSidebarPane(
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
23 changes: 12 additions & 11 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,7 @@ import type {
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import {
canSnooze,
resolveSnoozePresets,
type ChangeRequestSettleSource,
} from "@t3tools/client-runtime/state/thread-settled";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
Expand All@@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
resolveThreadListV2ChangeRequestState,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2Status,
resolveThreadListV2SwipeActions,
type ThreadListV2ChangeRequestState,
type ThreadListV2Status,
} from "./threadListV2";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
Expand DownExpand Up@@ -377,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
merge and close rules. Mirrors web's onChangeRequestState. */
readonly onChangeRequestState?: (
threadKey: string,
changeRequest: ChangeRequestSettleSource | null,
changeRequest: ThreadListV2ChangeRequestState | null,
) => void;
readonly projectCwd?: string | null;
readonly searchMatch?: EnvironmentThreadSearchMatch;
Expand DownExpand Up@@ -411,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const prUpdatedAt = pr?.updatedAt ?? null;
const threadKey = `${thread.environmentId}:${thread.id}`;
useEffect(() => {
onChangeRequestState?.(
threadKey,
prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
);
}, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
const changeRequest = resolveThreadListV2ChangeRequestState({
linkedPullRequest: thread.linkedPullRequest,
state: prState,
updatedAt: prUpdatedAt,
});
if (changeRequest === undefined) return;
onChangeRequestState?.(threadKey, changeRequest);
}, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);

const screenColor = useThemeColor("--color-screen");
const drawerColor = useThemeColor("--color-drawer");
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveThreadListV2ChangeRequestState,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -53,6 +54,48 @@ function makeThread(
}

const NOW = "2026-06-02T00:00:00.000Z";
const linkedPullRequest = {
projectId: ProjectId.make("project-1"),
repository: "pingdotgg/t3code",
number: 42,
url: "https://github.com/pingdotgg/t3code/pull/42",
};

describe("resolveThreadListV2ChangeRequestState", () => {
it("preserves the previous state while a linked pull request reloads", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: null,
updatedAt: null,
}),
).toBeUndefined();
});

it("clears the previous state after a pull request is unlinked", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest: null,
state: null,
updatedAt: null,
}),
).toBeNull();
});

it("reports a loaded linked pull request", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
}),
).toEqual({
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
});
});
});

describe("resolveThreadListV2SnoozeMenuSelection", () => {
it("accepts a displayed evening preset while its wake time is still future", () => {
Expand DownExpand Up@@ -263,6 +306,58 @@ describe("sortThreadsForListV2", () => {
});

describe("buildThreadListV2Items", () => {
it("ignores the previous pull request state after a different pull request is linked", () => {
const thread = makeThread({
id: ThreadId.make("linked"),
title: "Linked pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(0);
expect(layout.items[0]?.variant).toBe("card");
});

it("settles a thread only when the cached pull request identity matches", () => {
const thread = makeThread({
id: ThreadId.make("linked-merged"),
title: "Linked merged pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(1);
expect(layout.items[0]?.variant).toBe("slim");
});

it("keeps a merged thread active when auto-settle on merge is off", () => {
const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
const layout = buildThreadListV2Items({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion .agents/upstream-review.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
---
remote: t3code-upstream
branch: main
reviewed-through: "f035a0f4cdf4abaa6704673af7b5a4a321149ba2"
reviewed-through: "bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c"
reviewed-through-date: "2026-08-24"
---

Expand DownExpand Up@@ -2425,6 +2425,65 @@ Not done, and deliberately: no real-client pass in web or mobile. `#8009` redesi
and `$` menus, `#8048` changes composer attachment UI, and `#8058` changes approval prompts on
both clients; all three warrant a look in a running client before release.

## 2026-08-25 — `f035a0f4cdf4abaa6704673af7b5a4a321149ba2..bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c`

Twelve upstream commits grouped into ten change sets. The developer approved the full batch. Nine
sets produced source changes on `upstream/2026-08-25-bd9ed2-batch`; A6 was already present in
Pylon, so its cherry-pick was correctly empty. The deferred register was empty going in and remains
empty.

Pylon-first reconciliation was required in three places. A5's projection optimization now preserves
full shell-summary refreshes for Pylon's `interaction.requested`, `interaction.resolved`, and
`provider.interaction.respond.failed` activities, with a request/resolution regression test. A8
keeps Pylon's application ids, protocols, product/artifact names, local ad-hoc signing, and optional
passkey setup while enabling upstream's batched signed-build hook. A9's upstream migration 042 was
renumbered to **046** because Pylon already owns 042 through 045; using 042 would silently skip the
linked-PR column on existing Pylon databases.

| ID | Upstream | Decision | Pylon reference | Notes |
| --- | ------------------------------------------- | ------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `5f1147cad` / `#8124` | adopted | `0875df643` | Nightly-to-nightly skew compares the full date/run while stable/nightly compatibility remains core-version based. Pylon wording retained. |
| A2 | `8287f2c3a`, `a1379db81` / `#8111`, `#8108` | adopted | `adc19dbec`, `f4608a35c` | Usage skeleton matches the loaded layout and model rows follow the selected cost/token metric. Pylon's active-provider rationale retained. |
| A3 | `883e1a3cd` / `#7488` | adopted | `79b4bec99` | Terminal links show hover affordance only with the activation modifier; uppercase URL schemes also activate correctly. |
| A4 | `a09f92171`, `99960383d` / `#8081`, `#8098` | adopted | `2b7f2e6be`, `f7e8c7522` | Windows, spaced, HTML, and XML agent file links reach the integrated viewer on the applicable web/mobile surfaces. Unsafe schemes remain rejected. |
| A5 | `c034f51bb` / `#8150` | adopted with adaptation | `c21a6c05b`, `0f99d52af` | Routine assistant/tool events stop rescanning full histories. Pylon interaction activities remain summary-changing. |
| A6 | `2394998aa` / `#8163` | adopted (already present) | `877dad32a` | Both `@xmldom/xmldom` deprecation records already existed; the upstream cherry-pick was empty and skipped. |
| A7 | `143341b0b` / `#8089` | adopted | `92bbc7ccf` | Configurable `mod+shift+s` settles or restores the active thread and stays inactive in the terminal. |
| A8 | `63eb0429f` / `#8093` | adopted with adaptation | `d6a20af51` | Batches macOS codesign calls, trims staged dependencies, and excludes Windows terminal binaries without crossing Pylon's desktop identity boundaries. |
| A9 | `3c75eb113` / `#8160` | adopted with adaptation | `ddc4cfb50` | Persisted PR-to-thread linking across server, contracts, web, and mobile. Migration renumbered 042 → 046. |
| A10 | `bd9ed2b4b` / `#8161` | adopted | `21a40af90` | Web/desktop HEIC and HEIF attachments convert to guarded JPEG through a lazy `heic-to` decoder. |

Focused validation before ledger commit: 516 changed-behavior tests pass (285 web, 73 server, 87
contracts/client-runtime, 56 mobile, 15 version-skew), plus the macOS signing test. All seven
affected package typechecks pass. Lint reports 0 errors and two pre-existing `prefer-set-has`
warnings in an untouched part of `ProjectionSnapshotQuery.test.ts`; formatting is clean over all 70
changed source/doc/config files. `build-desktop-artifact.test.ts` has one pre-existing host-dependent
failure on Apple Silicon: its alleged cross-architecture fixture is arm64, so the native probe runs;
the same assertion fails on clean `pylon`.

No real-client pass ran during integration. Web/mobile UI verification still requires explicit
computer-use permission.

### Post-integration review

The final rebased branch received a separate source-level review after integration. A fresh fetch
confirmed that upstream `main` still ends at `bd9ed2b4`, so no commit appeared after the original
review. Patch coverage includes every upstream-touched file; the only path differences are the two
A9 migration files deliberately renamed from 042 to 046. A zero-context added-line scan found no T3
product name, app id, renderer protocol, artifact name, or hosted-origin drift. The migration
manifest is unique and ordered through 046. The Pylon-specific Prime activity kinds used to derive
pending input are a subset of the optimized refresh allowlist. Contracts, capability gating,
server projection and persistence, web, mobile, reverse actions, connection modes, and user docs
were each checked with no confirmed cross-surface omission.

A production web build also passed. It confirms that `heic-to/csp` resolves and remains a separate
lazy chunk, so HEIC support does not increase the initial bundle. Two risks remain visible for
maintainer review: that decoder chunk is 2.995 MB minified / 751 KB gzip on first use, and
`heic-to@1.5.2` declares LGPL-3.0 while Pylon has no artifact-level third-party notice flow. The
known Apple Silicon packaging-test baseline failure remains unrelated. Real-client screenshots and
manual interaction checks were not run because browser/computer-use permission was requested but
not granted.

## Deferred register

_The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const RELATIVE_FILE_PATH_PATTERN =
/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN =
/^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const POSIX_FILE_ROOT_PREFIXES = [
"/Users/",
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import {
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import type {
EnvironmentId,
SidebarProjectGroupingMode,
Expand DownExpand Up@@ -54,6 +53,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "../threads/threadListV2";
import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
Expand DownExpand Up@@ -491,15 +491,16 @@ export function HomeScreen(props: HomeScreenProps) {
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule, matching web.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
Expand DownExpand Up@@ -84,6 +83,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "./threadListV2";

Expand DownExpand Up@@ -377,15 +377,16 @@ function ThreadNavigationSidebarPane(
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
23 changes: 12 additions & 11 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,7 @@ import type {
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import {
canSnooze,
resolveSnoozePresets,
type ChangeRequestSettleSource,
} from "@t3tools/client-runtime/state/thread-settled";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
Expand All@@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
resolveThreadListV2ChangeRequestState,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2Status,
resolveThreadListV2SwipeActions,
type ThreadListV2ChangeRequestState,
type ThreadListV2Status,
} from "./threadListV2";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
Expand DownExpand Up@@ -377,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
merge and close rules. Mirrors web's onChangeRequestState. */
readonly onChangeRequestState?: (
threadKey: string,
changeRequest: ChangeRequestSettleSource | null,
changeRequest: ThreadListV2ChangeRequestState | null,
) => void;
readonly projectCwd?: string | null;
readonly searchMatch?: EnvironmentThreadSearchMatch;
Expand DownExpand Up@@ -411,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const prUpdatedAt = pr?.updatedAt ?? null;
const threadKey = `${thread.environmentId}:${thread.id}`;
useEffect(() => {
onChangeRequestState?.(
threadKey,
prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
);
}, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
const changeRequest = resolveThreadListV2ChangeRequestState({
linkedPullRequest: thread.linkedPullRequest,
state: prState,
updatedAt: prUpdatedAt,
});
if (changeRequest === undefined) return;
onChangeRequestState?.(threadKey, changeRequest);
}, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);

const screenColor = useThemeColor("--color-screen");
const drawerColor = useThemeColor("--color-drawer");
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveThreadListV2ChangeRequestState,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -53,6 +54,48 @@ function makeThread(
}

const NOW = "2026-06-02T00:00:00.000Z";
const linkedPullRequest = {
projectId: ProjectId.make("project-1"),
repository: "pingdotgg/t3code",
number: 42,
url: "https://github.com/pingdotgg/t3code/pull/42",
};

describe("resolveThreadListV2ChangeRequestState", () => {
it("preserves the previous state while a linked pull request reloads", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: null,
updatedAt: null,
}),
).toBeUndefined();
});

it("clears the previous state after a pull request is unlinked", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest: null,
state: null,
updatedAt: null,
}),
).toBeNull();
});

it("reports a loaded linked pull request", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
}),
).toEqual({
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
});
});
});

describe("resolveThreadListV2SnoozeMenuSelection", () => {
it("accepts a displayed evening preset while its wake time is still future", () => {
Expand DownExpand Up@@ -263,6 +306,58 @@ describe("sortThreadsForListV2", () => {
});

describe("buildThreadListV2Items", () => {
it("ignores the previous pull request state after a different pull request is linked", () => {
const thread = makeThread({
id: ThreadId.make("linked"),
title: "Linked pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(0);
expect(layout.items[0]?.variant).toBe("card");
});

it("settles a thread only when the cached pull request identity matches", () => {
const thread = makeThread({
id: ThreadId.make("linked-merged"),
title: "Linked merged pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(1);
expect(layout.items[0]?.variant).toBe("slim");
});

it("keeps a merged thread active when auto-settle on merge is off", () => {
const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
const layout = buildThreadListV2Items({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion .agents/upstream-review.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
---
remote: t3code-upstream
branch: main
reviewed-through: "f035a0f4cdf4abaa6704673af7b5a4a321149ba2"
reviewed-through: "bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c"
reviewed-through-date: "2026-08-24"
---

Expand DownExpand Up@@ -2425,6 +2425,65 @@ Not done, and deliberately: no real-client pass in web or mobile. `#8009` redesi
and `$` menus, `#8048` changes composer attachment UI, and `#8058` changes approval prompts on
both clients; all three warrant a look in a running client before release.

## 2026-08-25 — `f035a0f4cdf4abaa6704673af7b5a4a321149ba2..bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c`

Twelve upstream commits grouped into ten change sets. The developer approved the full batch. Nine
sets produced source changes on `upstream/2026-08-25-bd9ed2-batch`; A6 was already present in
Pylon, so its cherry-pick was correctly empty. The deferred register was empty going in and remains
empty.

Pylon-first reconciliation was required in three places. A5's projection optimization now preserves
full shell-summary refreshes for Pylon's `interaction.requested`, `interaction.resolved`, and
`provider.interaction.respond.failed` activities, with a request/resolution regression test. A8
keeps Pylon's application ids, protocols, product/artifact names, local ad-hoc signing, and optional
passkey setup while enabling upstream's batched signed-build hook. A9's upstream migration 042 was
renumbered to **046** because Pylon already owns 042 through 045; using 042 would silently skip the
linked-PR column on existing Pylon databases.

| ID | Upstream | Decision | Pylon reference | Notes |
| --- | ------------------------------------------- | ------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `5f1147cad` / `#8124` | adopted | `0875df643` | Nightly-to-nightly skew compares the full date/run while stable/nightly compatibility remains core-version based. Pylon wording retained. |
| A2 | `8287f2c3a`, `a1379db81` / `#8111`, `#8108` | adopted | `adc19dbec`, `f4608a35c` | Usage skeleton matches the loaded layout and model rows follow the selected cost/token metric. Pylon's active-provider rationale retained. |
| A3 | `883e1a3cd` / `#7488` | adopted | `79b4bec99` | Terminal links show hover affordance only with the activation modifier; uppercase URL schemes also activate correctly. |
| A4 | `a09f92171`, `99960383d` / `#8081`, `#8098` | adopted | `2b7f2e6be`, `f7e8c7522` | Windows, spaced, HTML, and XML agent file links reach the integrated viewer on the applicable web/mobile surfaces. Unsafe schemes remain rejected. |
| A5 | `c034f51bb` / `#8150` | adopted with adaptation | `c21a6c05b`, `0f99d52af` | Routine assistant/tool events stop rescanning full histories. Pylon interaction activities remain summary-changing. |
| A6 | `2394998aa` / `#8163` | adopted (already present) | `877dad32a` | Both `@xmldom/xmldom` deprecation records already existed; the upstream cherry-pick was empty and skipped. |
| A7 | `143341b0b` / `#8089` | adopted | `92bbc7ccf` | Configurable `mod+shift+s` settles or restores the active thread and stays inactive in the terminal. |
| A8 | `63eb0429f` / `#8093` | adopted with adaptation | `d6a20af51` | Batches macOS codesign calls, trims staged dependencies, and excludes Windows terminal binaries without crossing Pylon's desktop identity boundaries. |
| A9 | `3c75eb113` / `#8160` | adopted with adaptation | `ddc4cfb50` | Persisted PR-to-thread linking across server, contracts, web, and mobile. Migration renumbered 042 → 046. |
| A10 | `bd9ed2b4b` / `#8161` | adopted | `21a40af90` | Web/desktop HEIC and HEIF attachments convert to guarded JPEG through a lazy `heic-to` decoder. |

Focused validation before ledger commit: 516 changed-behavior tests pass (285 web, 73 server, 87
contracts/client-runtime, 56 mobile, 15 version-skew), plus the macOS signing test. All seven
affected package typechecks pass. Lint reports 0 errors and two pre-existing `prefer-set-has`
warnings in an untouched part of `ProjectionSnapshotQuery.test.ts`; formatting is clean over all 70
changed source/doc/config files. `build-desktop-artifact.test.ts` has one pre-existing host-dependent
failure on Apple Silicon: its alleged cross-architecture fixture is arm64, so the native probe runs;
the same assertion fails on clean `pylon`.

No real-client pass ran during integration. Web/mobile UI verification still requires explicit
computer-use permission.

### Post-integration review

The final rebased branch received a separate source-level review after integration. A fresh fetch
confirmed that upstream `main` still ends at `bd9ed2b4`, so no commit appeared after the original
review. Patch coverage includes every upstream-touched file; the only path differences are the two
A9 migration files deliberately renamed from 042 to 046. A zero-context added-line scan found no T3
product name, app id, renderer protocol, artifact name, or hosted-origin drift. The migration
manifest is unique and ordered through 046. The Pylon-specific Prime activity kinds used to derive
pending input are a subset of the optimized refresh allowlist. Contracts, capability gating,
server projection and persistence, web, mobile, reverse actions, connection modes, and user docs
were each checked with no confirmed cross-surface omission.

A production web build also passed. It confirms that `heic-to/csp` resolves and remains a separate
lazy chunk, so HEIC support does not increase the initial bundle. Two risks remain visible for
maintainer review: that decoder chunk is 2.995 MB minified / 751 KB gzip on first use, and
`heic-to@1.5.2` declares LGPL-3.0 while Pylon has no artifact-level third-party notice flow. The
known Apple Silicon packaging-test baseline failure remains unrelated. Real-client screenshots and
manual interaction checks were not run because browser/computer-use permission was requested but
not granted.

## Deferred register

_The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const RELATIVE_FILE_PATH_PATTERN =
/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN =
/^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const POSIX_FILE_ROOT_PREFIXES = [
"/Users/",
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import {
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import type {
EnvironmentId,
SidebarProjectGroupingMode,
Expand DownExpand Up@@ -54,6 +53,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "../threads/threadListV2";
import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
Expand DownExpand Up@@ -491,15 +491,16 @@ export function HomeScreen(props: HomeScreenProps) {
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule, matching web.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
Expand DownExpand Up@@ -84,6 +83,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "./threadListV2";

Expand DownExpand Up@@ -377,15 +377,16 @@ function ThreadNavigationSidebarPane(
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
23 changes: 12 additions & 11 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,7 @@ import type {
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import {
canSnooze,
resolveSnoozePresets,
type ChangeRequestSettleSource,
} from "@t3tools/client-runtime/state/thread-settled";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
Expand All@@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
resolveThreadListV2ChangeRequestState,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2Status,
resolveThreadListV2SwipeActions,
type ThreadListV2ChangeRequestState,
type ThreadListV2Status,
} from "./threadListV2";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
Expand DownExpand Up@@ -377,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
merge and close rules. Mirrors web's onChangeRequestState. */
readonly onChangeRequestState?: (
threadKey: string,
changeRequest: ChangeRequestSettleSource | null,
changeRequest: ThreadListV2ChangeRequestState | null,
) => void;
readonly projectCwd?: string | null;
readonly searchMatch?: EnvironmentThreadSearchMatch;
Expand DownExpand Up@@ -411,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const prUpdatedAt = pr?.updatedAt ?? null;
const threadKey = `${thread.environmentId}:${thread.id}`;
useEffect(() => {
onChangeRequestState?.(
threadKey,
prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
);
}, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
const changeRequest = resolveThreadListV2ChangeRequestState({
linkedPullRequest: thread.linkedPullRequest,
state: prState,
updatedAt: prUpdatedAt,
});
if (changeRequest === undefined) return;
onChangeRequestState?.(threadKey, changeRequest);
}, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);

const screenColor = useThemeColor("--color-screen");
const drawerColor = useThemeColor("--color-drawer");
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveThreadListV2ChangeRequestState,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -53,6 +54,48 @@ function makeThread(
}

const NOW = "2026-06-02T00:00:00.000Z";
const linkedPullRequest = {
projectId: ProjectId.make("project-1"),
repository: "pingdotgg/t3code",
number: 42,
url: "https://github.com/pingdotgg/t3code/pull/42",
};

describe("resolveThreadListV2ChangeRequestState", () => {
it("preserves the previous state while a linked pull request reloads", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: null,
updatedAt: null,
}),
).toBeUndefined();
});

it("clears the previous state after a pull request is unlinked", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest: null,
state: null,
updatedAt: null,
}),
).toBeNull();
});

it("reports a loaded linked pull request", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
}),
).toEqual({
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
});
});
});

describe("resolveThreadListV2SnoozeMenuSelection", () => {
it("accepts a displayed evening preset while its wake time is still future", () => {
Expand DownExpand Up@@ -263,6 +306,58 @@ describe("sortThreadsForListV2", () => {
});

describe("buildThreadListV2Items", () => {
it("ignores the previous pull request state after a different pull request is linked", () => {
const thread = makeThread({
id: ThreadId.make("linked"),
title: "Linked pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(0);
expect(layout.items[0]?.variant).toBe("card");
});

it("settles a thread only when the cached pull request identity matches", () => {
const thread = makeThread({
id: ThreadId.make("linked-merged"),
title: "Linked merged pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(1);
expect(layout.items[0]?.variant).toBe("slim");
});

it("keeps a merged thread active when auto-settle on merge is off", () => {
const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
const layout = buildThreadListV2Items({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion .agents/upstream-review.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
---
remote: t3code-upstream
branch: main
reviewed-through: "f035a0f4cdf4abaa6704673af7b5a4a321149ba2"
reviewed-through: "bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c"
reviewed-through-date: "2026-08-24"
---

Expand DownExpand Up@@ -2425,6 +2425,65 @@ Not done, and deliberately: no real-client pass in web or mobile. `#8009` redesi
and `$` menus, `#8048` changes composer attachment UI, and `#8058` changes approval prompts on
both clients; all three warrant a look in a running client before release.

## 2026-08-25 — `f035a0f4cdf4abaa6704673af7b5a4a321149ba2..bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c`

Twelve upstream commits grouped into ten change sets. The developer approved the full batch. Nine
sets produced source changes on `upstream/2026-08-25-bd9ed2-batch`; A6 was already present in
Pylon, so its cherry-pick was correctly empty. The deferred register was empty going in and remains
empty.

Pylon-first reconciliation was required in three places. A5's projection optimization now preserves
full shell-summary refreshes for Pylon's `interaction.requested`, `interaction.resolved`, and
`provider.interaction.respond.failed` activities, with a request/resolution regression test. A8
keeps Pylon's application ids, protocols, product/artifact names, local ad-hoc signing, and optional
passkey setup while enabling upstream's batched signed-build hook. A9's upstream migration 042 was
renumbered to **046** because Pylon already owns 042 through 045; using 042 would silently skip the
linked-PR column on existing Pylon databases.

| ID | Upstream | Decision | Pylon reference | Notes |
| --- | ------------------------------------------- | ------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `5f1147cad` / `#8124` | adopted | `0875df643` | Nightly-to-nightly skew compares the full date/run while stable/nightly compatibility remains core-version based. Pylon wording retained. |
| A2 | `8287f2c3a`, `a1379db81` / `#8111`, `#8108` | adopted | `adc19dbec`, `f4608a35c` | Usage skeleton matches the loaded layout and model rows follow the selected cost/token metric. Pylon's active-provider rationale retained. |
| A3 | `883e1a3cd` / `#7488` | adopted | `79b4bec99` | Terminal links show hover affordance only with the activation modifier; uppercase URL schemes also activate correctly. |
| A4 | `a09f92171`, `99960383d` / `#8081`, `#8098` | adopted | `2b7f2e6be`, `f7e8c7522` | Windows, spaced, HTML, and XML agent file links reach the integrated viewer on the applicable web/mobile surfaces. Unsafe schemes remain rejected. |
| A5 | `c034f51bb` / `#8150` | adopted with adaptation | `c21a6c05b`, `0f99d52af` | Routine assistant/tool events stop rescanning full histories. Pylon interaction activities remain summary-changing. |
| A6 | `2394998aa` / `#8163` | adopted (already present) | `877dad32a` | Both `@xmldom/xmldom` deprecation records already existed; the upstream cherry-pick was empty and skipped. |
| A7 | `143341b0b` / `#8089` | adopted | `92bbc7ccf` | Configurable `mod+shift+s` settles or restores the active thread and stays inactive in the terminal. |
| A8 | `63eb0429f` / `#8093` | adopted with adaptation | `d6a20af51` | Batches macOS codesign calls, trims staged dependencies, and excludes Windows terminal binaries without crossing Pylon's desktop identity boundaries. |
| A9 | `3c75eb113` / `#8160` | adopted with adaptation | `ddc4cfb50` | Persisted PR-to-thread linking across server, contracts, web, and mobile. Migration renumbered 042 → 046. |
| A10 | `bd9ed2b4b` / `#8161` | adopted | `21a40af90` | Web/desktop HEIC and HEIF attachments convert to guarded JPEG through a lazy `heic-to` decoder. |

Focused validation before ledger commit: 516 changed-behavior tests pass (285 web, 73 server, 87
contracts/client-runtime, 56 mobile, 15 version-skew), plus the macOS signing test. All seven
affected package typechecks pass. Lint reports 0 errors and two pre-existing `prefer-set-has`
warnings in an untouched part of `ProjectionSnapshotQuery.test.ts`; formatting is clean over all 70
changed source/doc/config files. `build-desktop-artifact.test.ts` has one pre-existing host-dependent
failure on Apple Silicon: its alleged cross-architecture fixture is arm64, so the native probe runs;
the same assertion fails on clean `pylon`.

No real-client pass ran during integration. Web/mobile UI verification still requires explicit
computer-use permission.

### Post-integration review

The final rebased branch received a separate source-level review after integration. A fresh fetch
confirmed that upstream `main` still ends at `bd9ed2b4`, so no commit appeared after the original
review. Patch coverage includes every upstream-touched file; the only path differences are the two
A9 migration files deliberately renamed from 042 to 046. A zero-context added-line scan found no T3
product name, app id, renderer protocol, artifact name, or hosted-origin drift. The migration
manifest is unique and ordered through 046. The Pylon-specific Prime activity kinds used to derive
pending input are a subset of the optimized refresh allowlist. Contracts, capability gating,
server projection and persistence, web, mobile, reverse actions, connection modes, and user docs
were each checked with no confirmed cross-surface omission.

A production web build also passed. It confirms that `heic-to/csp` resolves and remains a separate
lazy chunk, so HEIC support does not increase the initial bundle. Two risks remain visible for
maintainer review: that decoder chunk is 2.995 MB minified / 751 KB gzip on first use, and
`heic-to@1.5.2` declares LGPL-3.0 while Pylon has no artifact-level third-party notice flow. The
known Apple Silicon packaging-test baseline failure remains unrelated. Real-client screenshots and
manual interaction checks were not run because browser/computer-use permission was requested but
not granted.

## Deferred register

_The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const RELATIVE_FILE_PATH_PATTERN =
/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN =
/^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const POSIX_FILE_ROOT_PREFIXES = [
"/Users/",
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import {
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import type {
EnvironmentId,
SidebarProjectGroupingMode,
Expand DownExpand Up@@ -54,6 +53,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "../threads/threadListV2";
import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
Expand DownExpand Up@@ -491,15 +491,16 @@ export function HomeScreen(props: HomeScreenProps) {
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule, matching web.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,6 @@ import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
Expand DownExpand Up@@ -84,6 +83,7 @@ import {
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ChangeRequestState,
type ThreadListV2ListItem,
} from "./threadListV2";

Expand DownExpand Up@@ -377,15 +377,16 @@ function ThreadNavigationSidebarPane(
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule.
const [changeRequestByKey, setChangeRequestByKey] = useState<
ReadonlyMap<string, ChangeRequestSettleSource>
ReadonlyMap<string, ThreadListV2ChangeRequestState>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
(threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
setChangeRequestByKey((current) => {
const existing = current.get(threadKey) ?? null;
if (
(existing?.state ?? null) === (changeRequest?.state ?? null) &&
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
(existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
(existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
) {
return current;
}
Expand Down
23 changes: 12 additions & 11 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,7 @@ import type {
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import {
canSnooze,
resolveSnoozePresets,
type ChangeRequestSettleSource,
} from "@t3tools/client-runtime/state/thread-settled";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
Expand All@@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
resolveThreadListV2ChangeRequestState,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2Status,
resolveThreadListV2SwipeActions,
type ThreadListV2ChangeRequestState,
type ThreadListV2Status,
} from "./threadListV2";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
Expand DownExpand Up@@ -377,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
merge and close rules. Mirrors web's onChangeRequestState. */
readonly onChangeRequestState?: (
threadKey: string,
changeRequest: ChangeRequestSettleSource | null,
changeRequest: ThreadListV2ChangeRequestState | null,
) => void;
readonly projectCwd?: string | null;
readonly searchMatch?: EnvironmentThreadSearchMatch;
Expand DownExpand Up@@ -411,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const prUpdatedAt = pr?.updatedAt ?? null;
const threadKey = `${thread.environmentId}:${thread.id}`;
useEffect(() => {
onChangeRequestState?.(
threadKey,
prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
);
}, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
const changeRequest = resolveThreadListV2ChangeRequestState({
linkedPullRequest: thread.linkedPullRequest,
state: prState,
updatedAt: prUpdatedAt,
});
if (changeRequest === undefined) return;
onChangeRequestState?.(threadKey, changeRequest);
}, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);

const screenColor = useThemeColor("--color-screen");
const drawerColor = useThemeColor("--color-drawer");
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveThreadListV2ChangeRequestState,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -53,6 +54,48 @@ function makeThread(
}

const NOW = "2026-06-02T00:00:00.000Z";
const linkedPullRequest = {
projectId: ProjectId.make("project-1"),
repository: "pingdotgg/t3code",
number: 42,
url: "https://github.com/pingdotgg/t3code/pull/42",
};

describe("resolveThreadListV2ChangeRequestState", () => {
it("preserves the previous state while a linked pull request reloads", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: null,
updatedAt: null,
}),
).toBeUndefined();
});

it("clears the previous state after a pull request is unlinked", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest: null,
state: null,
updatedAt: null,
}),
).toBeNull();
});

it("reports a loaded linked pull request", () => {
expect(
resolveThreadListV2ChangeRequestState({
linkedPullRequest,
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
}),
).toEqual({
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
});
});
});

describe("resolveThreadListV2SnoozeMenuSelection", () => {
it("accepts a displayed evening preset while its wake time is still future", () => {
Expand DownExpand Up@@ -263,6 +306,58 @@ describe("sortThreadsForListV2", () => {
});

describe("buildThreadListV2Items", () => {
it("ignores the previous pull request state after a different pull request is linked", () => {
const thread = makeThread({
id: ThreadId.make("linked"),
title: "Linked pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(0);
expect(layout.items[0]?.variant).toBe("card");
});

it("settles a thread only when the cached pull request identity matches", () => {
const thread = makeThread({
id: ThreadId.make("linked-merged"),
title: "Linked merged pull request",
linkedPullRequest,
});
const layout = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([
[
`${environmentId}:${thread.id}`,
{
state: "merged" as const,
linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
},
],
]),
now: NOW,
});

expect(layout.settledCount).toBe(1);
expect(layout.items[0]?.variant).toBe("slim");
});

it("keeps a merged thread active when auto-settle on merge is off", () => {
const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
const layout = buildThreadListV2Items({
Expand Down
Loading
Loading