diff --git a/FORK.md b/FORK.md index 04e651f1a858..56b96b8fe6a1 100644 --- a/FORK.md +++ b/FORK.md @@ -181,6 +181,7 @@ These are direct edits to upstream-owned files that exist only because upstream - **Project identity honors `gh repo set-default`** (register D31; Jackson's 2026-10-11 decision). `apps/server/src/project/RepositoryIdentityResolver.ts` reads `remote..gh-resolved` beside `git remote -v` and prefers the marked remote (`base`, or `OWNER/REPO` on that remote's host) before upstream's `upstream`, then `origin` order. Without it a fork developed as its own project is identified as its parent, so the `#` picker and the pull request view read the parent's pull requests. The hunks and the two tests in `RepositoryIdentityResolver.test.ts` are a port of pingdotgg/t3code#14291, which upstream closed unmerged; its replacement, pingdotgg/t3code#21574, is open and fixes the same issue (pingdotgg/t3code#15023). The hunks were re-applied on upstream's new cache key at the 2026-10-24 advance. Watch signal: upstream merges #21574, or settles fork remotes another way. Exit: take upstream's file and follow its rule, even if it differs. - **Storage cleanup reads Git 2.56's broken-gitfile message.** `apps/server/src/storageCleanup.ts` treats "does not point to a valid repository" like "not a git repository" when it checks whether a worktree's repository or branch changed. Git 2.56 reports a worktree whose `.git` file points at a missing directory with the new message, so cleanup reported that worktree as failed instead of kept, and `storageCleanup.test.ts` failed on CI runners with Git 2.56 (#768). Upstream's file has the same check. Watch signal: upstream matches the Git 2.56 message. Exit: remove this hunk and take upstream's. - **Saved-usage window test pins its clock.** `apps/server/src/usage/UsageService.test.ts` takes upstream's `1280ccd0d2` (pingdotgg/t3code#28603) unchanged: "reports saved usage of a removed directory only for windows it reaches" sets `TestClock` to 2026-08-15 and provides `TestClock.layer()`. The test ran on the wall clock with a transcript dated 2026-08-01, and the service prunes saved usage older than 92 days, so from 2026-11-01 it failed on every J5 PR. Exit: drop this entry at the next upstream advance, which brings the same commit; the file then equals upstream's. +- **The mobile Working section releases a finished thread** (#842). `apps/mobile/src/features/threads/useThreadListV2.ts` takes upstream's own fix ahead of the pin: pingdotgg/t3code#28169 (`be58795d37`, merged 2026-10-31), applied unchanged, so the file equals upstream's at that commit. Upstream's `useMemo` listed the inbox tracker's version without reading it, and the React Compiler memoizes on what a callback reads, so a thread whose exit grace expired stayed under Working until some thread shell changed; Jackson hit it on his iPhone at Checkpoint 1. `apps/mobile/vite.config.ts` adds J5's `useThreadListV2.test.tsx` to `reactTests`, so the test runs under the release compiler. Exit: at the next advance, take upstream's `useThreadListV2.ts`; keep the test and its `reactTests` entry, or drop both if upstream adds its own. Retired at the 2026-10-08 advance, when upstream's own fix landed or the maintainer ruled the patch out: the Codex generator steps and the protocol drift they absorbed (upstream regenerated its bindings), the migrated Codex replay transcripts (the fixtures are upstream's, unmodified; `scripts/j5/migrate-codex-fixtures.mjs` stays for the old recordings should upstream's tests need it), the SQLite boolean bindings (upstream's `booleanInt`), the remote pairing scopes (pingdotgg/t3code#9785 merged), committed Stop (register D5 and D23, removed by decision 10; its watch signal went with it), and the skip on upstream's V1 cutover test (`orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts` equals upstream's and passes). @@ -839,6 +840,8 @@ explicitly marked. | `apps/server/src/serviceLauncher.ts` | R | H, 50, 85 | | `apps/server/src/storageCleanup.ts` | N | Git 2.56 gitfile patch | | `apps/server/src/usage/UsageService.test.ts` | N | saved-usage clock pin | +| `apps/mobile/src/features/threads/useThreadListV2.ts` | N | Working section release patch | +| `apps/mobile/vite.config.ts` | N | Working section release patch | | `apps/server/src/telemetry/AnalyticsService.ts` | N | B, 63, 86 | | `apps/server/src/telemetry/FeatureUsage.ts` | N | 96 | | `apps/server/src/testUtils/shardWeights.json` | N | 65 | diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx new file mode 100644 index 000000000000..db531a78081f --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -0,0 +1,83 @@ +import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +vi.mock("../../state/olderSettledShelf", () => ({ + useOlderSettledShelf: () => ({ + isListed: () => true, + unloadedCount: () => 0, + loading: false, + failed: false, + }), + useOlderSettledShelfGrowth: () => {}, +})); + +import { makeThreadShellFixture } from "../../test-fixtures"; +import { useThreadListV2 } from "./useThreadListV2"; + +const NOW = "2026-06-02T00:00:00.000Z"; +const running = { + status: "running" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: NOW, +}; + +// Runs under the release React Compiler (vite.config.ts `reactTests`), as the app does. +it("returns a finished thread from Working once its grace ends, with nothing else changing", () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + const container = document.createElement("div"); + const root = createRoot(container); + try { + const working = makeThreadShellFixture({ + id: ThreadId.make("new-thread"), + title: "Say hello", + runtime: running, + }); + const finished = { ...working, runtime: null }; + function Probe({ threads }: { readonly threads: ReadonlyArray }) { + const { layout } = useThreadListV2({ + threads, + pendingTasks: [], + environmentId: null, + projectRefs: null, + searchQuery: "", + searchMatches: new Map(), + settlementEnvironmentIds: new Set(), + snoozeEnvironmentIds: new Set(), + pinReorderEnvironmentIds: new Set(), + activeReorderEnvironmentIds: new Set(), + queuedThreadKeys: new Set(), + settledLimit: 10, + now: NOW, + pendingOrder: null, + shelfPreferencesLoaded: true, + workingShelfEnabled: true, + workingShelfExpanded: false, + snoozedShelfExpanded: false, + settledShelfExpanded: false, + selectedThreadKey: null, + }); + return {layout.workingCount}; + } + const workingCount = () => Number(container.textContent); + const workingThreads = [working]; + const finishedThreads = [finished]; + act(() => root.render()); + expect(workingCount()).toBe(1); + // The turn ends: the shelf holds the thread for the grace period. + act(() => root.render()); + expect(workingCount()).toBe(1); + // Only the grace timer fires; the thread list itself does not change again. + act(() => vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS)); + expect(workingCount()).toBe(0); + } finally { + act(() => root.unmount()); + vi.useRealTimers(); + } +}); diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 2a6f3504e2f5..1a0c67062fe2 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -27,6 +27,17 @@ function useKeySet(newlineSeparatedKeys: string): ReadonlySet { type LayoutInput = Parameters[0]; +/** + * The React Compiler memoizes on the values a callback reads and drops unread entries from a + * dependency list, so the version that marks an expired grace must be read to rebuild the list. + */ +function observeInbox( + threads: Parameters[0], + _expiredGraceVersion: number, +) { + return threadListInboxReturns.observe(threads); +} + /** * Home's and the thread sidebar's thread list. Its result is one value a screen only reads: * built inline, the list's inputs stayed open to mutation across the screen's other hooks, so @@ -62,8 +73,7 @@ export function useThreadListV2(input: { threadListInboxReturns.getSnapshot, ); const inbox = useMemo( - () => threadListInboxReturns.observe(workingShelfEnabled ? threads : null), - // oxlint-disable-next-line react/memo-dependencies -- the version signals an expired grace + () => observeInbox(workingShelfEnabled ? threads : null, inboxVersion), [threads, workingShelfEnabled, inboxVersion], ); // Up/down menu availability for every card, computed once per section per diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 8eaaaa1604fb..4fd8f015fd0b 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -24,6 +24,7 @@ const reactTests = [ "src/state/queries.debounce.test.tsx", "src/state/use-selected-thread-git-actions.test.tsx", "src/features/terminal/useTerminalExitNavigation.test.tsx", + "src/features/threads/useThreadListV2.test.tsx", ]; const sourceRoot = `${normalizePath(NodeURL.fileURLToPath(new URL("./src", import.meta.url)))}/`;