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
75 changes: 70 additions & 5 deletions .agents/upstream-review.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
remote: t3code-upstream
branch: main
reviewed-through: "bad1143b02f7b585d1fe1335b3d9a97983ce8d8b"
reviewed-through-date: "2026-08-13"
reviewed-through: "5304f3e9d4c912bfa0eb2f5f41fa109b3646236b"
reviewed-through-date: "2026-08-14"
---

# T3 upstream review log
Expand Down Expand Up @@ -945,12 +945,77 @@ Two tooling notes worth carrying forward:
markdown tests ran against pre-cherry-pick source and failed against the
updated expectations. **Re-run `vp i` after changing one of these modules.**

## 2026-08-14 — `bad1143b02f7b585d1fe1335b3d9a97983ce8d8b..5304f3e9d4c912bfa0eb2f5f41fa109b3646236b`

Six commits, six change sets, **four adopted and two skipped**. Nothing
deferred, so the register stays empty. `git cherry` reported all six as `+`.

Conflict risk was measured by dry cherry-pick against the merged `pylon` before
the brief was written: four clean, two conflicting — and both conflicts landed
exactly on Pylon-owned boundaries, which is the system working.

Adopted across two branches, split by surface rather than by size:
`upstream/2026-08-14-web` (PR #15) and `upstream/2026-08-14-mobile` (PR #16).

| Change set | Upstream | Decision | Pylon reference | Rationale or revisit condition |
| ---------- | --------------------- | -------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N1 | `db1507e98` / `#5880` | adopted | `07a3a846c` | A setting to stop threads auto-settling when their pull request merges. Additive `sidebarAutoSettleOnMerge`, **default `true`**, so existing behaviour is unchanged; closed pull requests still always settle. Upstream covered contracts, client-runtime, web, and mobile. |
| N2 | `96bfa67b3` / `#6215` | adopted | `fb284b0f9` | Snoozed-thread wake icon sits on the row's optical centre. Generic geometry, applies to Pylon's sidebar unchanged. |
| N3 | `23d45d914` / `#6535` | adopted | `570b54268`, `f7311ad81` | Stage artwork palette rework: explicit night pigments plus glow and sparkle, with the old `color-mix` derivation scoped to `t3-chat`, `ocean`, and `iris` — all of which Pylon still ships. Its "T3 Code artwork palettes" comment was rebranded, following `O11`. |
| N5 | `85389b988` / `#6224` | adopted | `bbd3db454`, `5990b2440` | Mobile task and thread settings nest in bottom sheets. 41 files, +3,652/−1,836, **including native patches** to `react-native-screens` and `@react-navigation/native-stack`. Verified by a real prebuild, `pod install`, native build, and simulator pass — see below. |
| N4 | `5ff3a03ad` / `#6086` | skipped | `—` | Adds `-translate-y-px` to the sidebar brand label. A 1px optical correction measured against `<T3Wordmark />`; Pylon renders `<PylonMark />` plus the word "Pylon", a different glyph. **Cherry-picks clean and is still wrong for the fork.** Revisit only as Pylon-owned alignment work against Pylon's own mark. |
| N6 | `5304f3e9d` | skipped | `—` | Bumps the mobile app version to `1.0.4`. Pylon's `app.config.ts` is independent (`slug: "pylon"`, `pylon-code*` schemes, version `1.0.1`), so the bump conflicts and carries no Pylon meaning. Same class as any T3 release chore. |

**`N5` corrected a stale fact this ledger itself recorded.** The ninth batch's
mobile notes said the `t3code-dev://` URL scheme "is still compatibility-named
and works". It does not. A built `PylonDev.app` registers exactly
`pylon-code-dev` and `com.pylon.code.dev`:

```
$ plutil -extract CFBundleURLTypes json -o - .../PylonDev.app/Info.plist
[{"CFBundleURLSchemes":["pylon-code-dev","com.pylon.code.dev"]},{"CFBundleURLSchemes":["exp+pylon"]}]
```

`apps/mobile/src/App.tsx` still lists `t3code-dev://` among React Navigation's
linking prefixes, which is likely how the belief survived, but iOS never
delivers an unregistered scheme so the prefix is unreachable. Upstream's new
`pair-client.sh` defaulted to that scheme and hardcoded `com.t3tools.t3code.dev`,
so it would have failed silently on Pylon. The helper, the `test-pylon-mobile`
skill's identity block, and its `T3CodeDev.xcworkspace` references were all
corrected to what `app.config.ts` produces: `Pylon Dev`, `com.pylon.code.dev`,
`pylon-code-dev`, `PylonDev.xcworkspace`, scheme `PylonDev`.

Verification: 264 tests over four files and five clean package typechecks on the
web branch; 40 tests over six files and a clean mobile typecheck on the mobile
branch; `vp lint` clean on both. `N5` additionally got a full native rebuild and
an iOS Simulator pass — build succeeded in 176s with the new patches, the app
launched as `com.pylon.code.dev`, the corrected deep link routed, and both the
Add Environment route and the new "Choose project" context picker rendered as
nested bottom sheets. `N3` was captured before and after on one dev server by
swapping `index.css` over HMR, so the comparison holds data and layout constant.

**Android was not exercised for `N5`**, and the thread-settings sheet was not
driven on a live thread because the simulator was reconnecting to a
previously-paired real environment.

Two environment notes worth carrying forward:

- **CocoaPods 1.17.0 crashes under Ruby 4.0.6** with `Unicode Normalization not
appropriate for ASCII-8BIT` when `LANG`/`LC_ALL` are unset, which is how
`expo prebuild` leaves the shell. The ninth batch predicted this; setting a
UTF-8 locale for `pod install` fixes it. `expo prebuild` **exits 0 even when
its CocoaPods step fails**, leaving `ios/` without an `.xcworkspace`.
- **`apps/mobile/package.json`'s `dev:client` script still passes
`--scheme t3code-dev`**, which no longer matches the registered native scheme.
Left alone as out of scope for an adoption batch, but it is a real bug worth
its own fix.

## Deferred register

_The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11
(see the sixth batch above); the 2026-08-12 eighth batch and the 2026-08-13
ninth batch each deferred nothing new. Entries are removed once adopted or
skipped, so an empty register means nothing is waiting._
(see the sixth batch above); the eighth, ninth, and 2026-08-14 tenth batches
each deferred nothing new. Entries are removed once adopted or skipped, so an
empty register means nothing is waiting._

Upstream work that has been reviewed and consciously _not_ adopted yet, with
the condition that should trigger a fresh look. Entries stay here until they
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
providerModelPreferences: {},
showProviderUsageInContextPopover: true,
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleOnMerge: true,
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ export function HomeScreen(props: HomeScreenProps) {
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleOnMerge =
!AsyncResult.isSuccess(preferencesResult) ||
preferencesResult.value.autoSettleOnMerge !== false;
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand Down Expand Up @@ -483,8 +486,8 @@ export function HomeScreen(props: HomeScreenProps) {
// Settled threads stay in the live shell stream (settled ≠ archived), so
// the partition works directly off live shells — no snapshot merging or
// optimistic holds.
// PR states stream in per-row (rows own the VCS subscriptions); a merged or
// closed PR auto-settles its thread on the next partition (mirrors web).
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule, matching web.
const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
ReadonlyMap<string, "open" | "closed" | "merged">
>(() => new Map());
Expand Down Expand Up @@ -665,6 +668,7 @@ export function HomeScreen(props: HomeScreenProps) {
searchQuery: props.searchQuery,
matchedThreadKeys,
changeRequestStateByKey,
autoSettleOnMerge,
settlementEnvironmentIds,
snoozeEnvironmentIds,
settledLimit: settledVisibleCount,
Expand All @@ -676,6 +680,7 @@ export function HomeScreen(props: HomeScreenProps) {
});
}, [
changeRequestStateByKey,
autoSettleOnMerge,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,9 +522,21 @@ function ConfiguredSettingsRouteScreen() {
}

function GeneralSettingsSection() {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const autoSettleOnMerge =
!AsyncResult.isSuccess(preferencesResult) ||
preferencesResult.value.autoSettleOnMerge !== false;

return (
<SettingsSection title="General">
<SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
<SettingsSwitchRow
icon="arrow.triangle.branch"
label="Auto-settle merged threads"
value={autoSettleOnMerge}
onValueChange={(value) => savePreferences({ autoSettleOnMerge: value })}
/>
<SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
</SettingsSection>
);
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { LegendList } from "@legendapp/list/react-native";
import type { MenuAction } from "@react-native-menu/menu";
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 { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
Expand All @@ -29,6 +30,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { useThemeColor } from "../../lib/useThemeColor";
import { useProjects, useThreadShells } from "../../state/entities";
import { mobilePreferencesAtom } from "../../state/preferences";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
import { environmentServerConfigsAtom } from "../../state/server";
Expand Down Expand Up @@ -214,6 +216,10 @@ function ThreadNavigationSidebarPane(
regenerateThreadTitle,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const autoSettleOnMerge =
!AsyncResult.isSuccess(preferencesResult) ||
preferencesResult.value.autoSettleOnMerge !== false;
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand Down Expand Up @@ -411,8 +417,8 @@ function ThreadNavigationSidebarPane(

// Thread List v2 (beta) support — same model as the compact Home list
// (HomeScreen.tsx): flat creation-order card block + settled recency tail.
// PR states stream in per-row; merged/closed PRs auto-settle their thread
// on the next partition.
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule.
const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
ReadonlyMap<string, "open" | "closed" | "merged">
>(() => new Map());
Expand Down Expand Up @@ -546,6 +552,7 @@ function ThreadNavigationSidebarPane(
searchQuery: props.searchQuery,
matchedThreadKeys,
changeRequestStateByKey,
autoSettleOnMerge,
settlementEnvironmentIds,
snoozeEnvironmentIds,
settledLimit: settledVisibleCount,
Expand All @@ -557,6 +564,7 @@ function ThreadNavigationSidebarPane(
});
}, [
changeRequestStateByKey,
autoSettleOnMerge,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly canMovePinnedDown?: boolean;
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
readonly onSwipeableClose: (methods: SwipeableMethods) => void;
/** Reports this row's live PR state up so the partition can auto-settle
merged/closed work (mirrors web's onChangeRequestState). */
/** Reports this row's live PR state for the partition's merge and close
rules. Mirrors web's onChangeRequestState. */
readonly onChangeRequestState?: (
threadKey: string,
state: "open" | "closed" | "merged" | null,
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,21 @@ describe("sortThreadsForListV2", () => {
});

describe("buildThreadListV2Items", () => {
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({
threads: [merged],
environmentId: null,
searchQuery: "",
changeRequestStateByKey: new Map([[`${environmentId}:${merged.id}`, "merged"]]),
autoSettleOnMerge: false,
now: NOW,
});

expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(layout.settledCount).toBe(0);
});

it("hides snoozed threads and counts them — visibility parity with web", () => {
const layout = buildThreadListV2Items({
threads: [
Expand Down
14 changes: 10 additions & 4 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,8 @@ export function buildThreadListV2ListItems(input: {

/**
* Partitions visible threads into the active card block (creation order) and
* the settled recency tail, matching the web v2 list. `autoSettleAfterDays`
* mirrors the web default of 3 — mobile has no client-settings sync yet, so
* the default is fixed here rather than user-configurable.
* the settled recency tail, matching the web v2 list. Mobile stores these
* auto-settle preferences per device.
*/
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
Expand All @@ -329,6 +328,7 @@ export function buildThreadListV2Items(input: {
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly autoSettleAfterDays?: number;
readonly autoSettleOnMerge?: boolean;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
Expand All @@ -349,6 +349,7 @@ export function buildThreadListV2Items(input: {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const autoSettleOnMerge = input.autoSettleOnMerge ?? true;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
Expand Down Expand Up @@ -405,7 +406,12 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
effectiveSettled(thread, {
now,
autoSettleAfterDays,
autoSettleOnMerge,
changeRequestState,
})
) {
settled.push(thread);
} else {
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface Preferences {
/** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
readonly projectGroupingEnabled?: boolean;
readonly projectGroupingMode?: SidebarProjectGroupingMode;
readonly autoSettleOnMerge?: boolean;
/**
* Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
* no client-settings sync, so the legacy grouped thread list is opted into
Expand Down Expand Up @@ -85,6 +86,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
collapsedProjectGroups?: readonly string[];
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
autoSettleOnMerge?: boolean;
legacyThreadListEnabled?: boolean;
} = {};

Expand Down Expand Up @@ -122,6 +124,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
) {
preferences.projectGroupingMode = parsed.projectGroupingMode;
}
if (typeof parsed.autoSettleOnMerge === "boolean") {
preferences.autoSettleOnMerge = parsed.autoSettleOnMerge;
}
if (typeof parsed.legacyThreadListEnabled === "boolean") {
preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
}
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
import {
changeRequestAutoSettles,
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
Expand Down Expand Up @@ -4105,6 +4106,7 @@ function ChatViewContent(props: ChatViewProps) {
// so the banner and the sidebar row never disagree.
const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
const activeThreadPr = resolveThreadPr({
threadBranch: activeThread?.branch ?? null,
gitStatus: gitStatusQuery.data ?? null,
Expand Down Expand Up @@ -4145,15 +4147,14 @@ function ChatViewContent(props: ChatViewProps) {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
// Mirror of the sidebar's Woke pill for the open thread: same visit
// comparison, same merged/closed-PR suppression (finished work needs no
// wake-up call). Drives the dismissible composer banner below.
// Mirror of the sidebar's Woke pill for the open thread. It uses the same
// visit comparison and change request settle rule.
const activeThreadLastVisitedAt = useUiStateStore((store) =>
activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
);
const activeThreadWokeVisible = useMemo(() => {
if (activeThreadWokeAt === null) return false;
if (activeThreadPr?.state === "merged" || activeThreadPr?.state === "closed") return false;
if (changeRequestAutoSettles(activeThreadPr?.state, autoSettleOnMerge)) return false;
const wokeAtMs = Date.parse(activeThreadWokeAt);
if (Number.isNaN(wokeAtMs)) return false;
// Having the thread open counts as a visit at completedAt (the effect
Expand All @@ -4175,18 +4176,21 @@ function ChatViewContent(props: ChatViewProps) {
activeThreadLastVisitedAt,
activeThreadPr?.state,
activeThreadWokeAt,
autoSettleOnMerge,
]);
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
now: `${nowMinute}:00.000Z`,
autoSettleAfterDays,
autoSettleOnMerge,
changeRequestState: activeThreadPr?.state ?? null,
});
}, [
activeThreadPr?.state,
activeThreadShell,
autoSettleAfterDays,
autoSettleOnMerge,
nowMinute,
supportsSettlement,
]);
Expand Down
Loading
Loading