From 1e4447c38e76863f4dbb83e1496c93e045f6430c Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Mon, 10 Aug 2026 10:46:09 -0400 Subject: [PATCH 1/3] fix(threads): keep un-settled threads active after their PR merges Port of fork-main PR #61 onto turbo. Un-settling a thread whose PR had merged only lasted minutes: any session start or message cleared the keep-active pin back to neutral, and the merged-PR rule then re-settled the thread the moment the burst of work went quiet. - The explicit keep-active pin is now sticky: activity still wakes an explicitly SETTLED thread, but no longer spends the user's "not done" pin. Only an explicit settle clears it. (decider.ts, three sites) - Instant merged/closed-PR auto-settle applies only when the thread went quiet at or before the PR's completion; newer activity defers to the inactivity rule, fed by the PR's updatedAt now carried on the VCS status contract into web and mobile classification. Turbo specifics: the SidebarV2.tsx hunks are re-applied to Sidebar.tsx (upstream #5672 made v2 the default sidebar); new seam settled-lifecycle-sticky-pin registered in .t3-turbo/customizations.json (verify passes: 26 seams, 172 checks); changelog entry added. Upstream context: pingdotgg/t3code#5575, pingdotgg/t3code#5643. Co-Authored-By: Claude Fable 5 --- .t3-turbo/customizations.json | 40 ++++++++++++ SEAM.md | 23 +++++++ apps/mobile/src/features/home/HomeScreen.tsx | 14 +++-- .../threads/ThreadNavigationSidebar.tsx | 14 +++-- .../features/threads/thread-list-v2-items.tsx | 9 ++- .../src/features/threads/threadListV2.ts | 18 ++++-- .../src/state/thread-pr-presentation.ts | 4 ++ apps/server/src/git/GitManager.test.ts | 43 +++++++++++++ apps/server/src/git/GitManager.ts | 3 + .../src/orchestration/decider.settled.test.ts | 33 ++++++---- apps/server/src/orchestration/decider.ts | 28 ++++++--- apps/web/src/components/ChatView.tsx | 3 + apps/web/src/components/Sidebar.tsx | 42 +++++++++---- apps/web/src/components/chat/ChatHeader.tsx | 4 ++ apps/web/src/hooks/useThreadActionMenu.ts | 7 ++- docs/operations/turbo-changelog.md | 8 +++ .../src/state/threadSettled.test.ts | 61 +++++++++++++++++++ .../client-runtime/src/state/threadSettled.ts | 36 ++++++++--- packages/contracts/src/git.ts | 5 ++ 19 files changed, 340 insertions(+), 55 deletions(-) diff --git a/.t3-turbo/customizations.json b/.t3-turbo/customizations.json index a6bddaaaf3b2..0b8370c34ac7 100644 --- a/.t3-turbo/customizations.json +++ b/.t3-turbo/customizations.json @@ -1274,6 +1274,46 @@ "markers": ["**Tuned** `packages/client-runtime/src/rpc/client.ts`"] } ] + }, + { + "id": "settled-lifecycle-sticky-pin", + "status": "implemented", + "summary": "Un-settling a thread is durable and a merged/closed PR only insta-settles a thread that went quiet at completion. The decider no longer spends the keep-active pin on activity (it still wakes explicitly settled threads), and effectiveSettled defers a completed PR to the inactivity rule when thread activity is newer than the PR updatedAt, which now rides the VCS status contract from GitManager into web and mobile classification. Upstream candidate: pingdotgg/t3code#5575 / #5643; drop this seam if upstream merges an equivalent.", + "checks": [ + { + "path": "apps/server/src/orchestration/decider.ts", + "markers": [ + "deliberately sticky", + "if (targetThread.settledOverride === \"settled\") {", + "thread.settledOverride !== \"settled\" || !isSessionActivity", + "thread.settledOverride !== \"settled\" || !wakesSettledThread" + ] + }, + { + "path": "packages/client-runtime/src/state/threadSettled.ts", + "markers": ["changeRequestUpdatedAt", "activityAfterCompletion"] + }, + { + "path": "packages/contracts/src/git.ts", + "markers": ["updatedAt: Schema.optional(Schema.String)"] + }, + { + "path": "apps/server/src/git/GitManager.ts", + "markers": ["Option.map(pr.updatedAt, DateTime.formatIso)"] + }, + { + "path": "apps/web/src/components/Sidebar.tsx", + "markers": ["changeRequestUpdatedAt: changeRequest?.updatedAt ?? null"] + }, + { + "path": "apps/mobile/src/features/threads/threadListV2.ts", + "markers": ["changeRequestUpdatedAt: changeRequest?.updatedAt ?? null"] + }, + { + "path": "SEAM.md", + "markers": ["Settled-lifecycle fix (upstream candidate)"] + } + ] } ] } diff --git a/SEAM.md b/SEAM.md index fd6e487a18d8..9b6c2ad437a5 100644 --- a/SEAM.md +++ b/SEAM.md @@ -370,6 +370,29 @@ fork's change. `awaitPooled` stops stepping the moment the wait resolves and that is what keeps the 250 ms retry and 500 ms persistence assertions in these files honest. +## Settled-lifecycle fix (upstream candidate) + +One behavioral change carried until upstream lands its own (see pingdotgg/t3code#5575 / +pingdotgg/t3code#5643): the explicit un-settle pin is sticky against activity, and a merged/closed +PR only insta-settles a thread whose activity is not newer than the PR's `updatedAt`. + +- **Behavioral** `apps/server/src/orchestration/decider.ts` — activity un-settles only a `"settled"` + override; the `"active"` keep-alive pin survives messages, session starts, and approval/input + requests (three sites). +- **Behavioral** `packages/client-runtime/src/state/threadSettled.ts` — `effectiveSettled` accepts + `changeRequestUpdatedAt`; post-completion activity defers a merged/closed PR to the inactivity + rule. +- **Additive** `packages/contracts/src/git.ts` — optional `updatedAt` on `VcsStatusChangeRequest`. +- **Additive** `apps/server/src/git/GitManager.ts` — `toStatusPr` forwards the PR's `updatedAt`. +- **Additive** web (`SidebarV2.tsx`, `ChatView.tsx`, `chat/ChatHeader.tsx`, + `hooks/useThreadActionMenu.ts`) and mobile (`threadListV2.ts`, `thread-list-v2-items.tsx`, + `HomeScreen.tsx`, `ThreadNavigationSidebar.tsx`, `state/thread-pr-presentation.ts`) — thread the + PR `updatedAt` into the settled classification. + +On a nightly-sync conflict here, prefer upstream's version wholesale if upstream has merged an +equivalent (a sticky un-settle or a completed-PR settle gate/toggle); otherwise reapply only the +behavior above. + ## Nightly sync conflicts Resolve against the new upstream file first, then reapply only the behavior above; never take the diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 64f0480d2231..ba95fa836656 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -485,17 +485,23 @@ export function HomeScreen(props: HomeScreenProps) { // 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). const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap + ReadonlyMap< + string, + { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } + > >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { + (threadKey: string, state: "open" | "closed" | "merged" | null, updatedAt: string | null) => { setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; + const existing = current.get(threadKey) ?? null; + if ((existing?.state ?? null) === state && (existing?.updatedAt ?? null) === updatedAt) { + return current; + } const next = new Map(current); if (state === null) { next.delete(threadKey); } else { - next.set(threadKey, state); + next.set(threadKey, { state, updatedAt }); } return next; }); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d80d906ada17..5caea851d221 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -413,17 +413,23 @@ function ThreadNavigationSidebarPane( // PR states stream in per-row; merged/closed PRs auto-settle their thread // on the next partition. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap + ReadonlyMap< + string, + { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } + > >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { + (threadKey: string, state: "open" | "closed" | "merged" | null, updatedAt: string | null) => { setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; + const existing = current.get(threadKey) ?? null; + if ((existing?.state ?? null) === state && (existing?.updatedAt ?? null) === updatedAt) { + return current; + } const next = new Map(current); if (state === null) { next.delete(threadKey); } else { - next.set(threadKey, state); + next.set(threadKey, { state, updatedAt }); } return next; }); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 17b6a4ea965a..364c1c16890b 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -366,10 +366,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { 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). */ + merged/closed work (mirrors web's onChangeRequestState). updatedAt + rides along so post-merge activity can hold the thread active. */ readonly onChangeRequestState?: ( threadKey: string, state: "open" | "closed" | "merged" | null, + updatedAt: string | null, ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; @@ -399,10 +401,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); const prState = pr?.state ?? null; + const prUpdatedAt = pr?.updatedAt ?? null; const threadKey = `${thread.environmentId}:${thread.id}`; useEffect(() => { - onChangeRequestState?.(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + onChangeRequestState?.(threadKey, prState, prUpdatedAt); + }, [onChangeRequestState, prState, prUpdatedAt, threadKey]); const screenColor = useThemeColor("--color-screen"); const drawerColor = useThemeColor("--color-drawer"); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index eba56ac8de5e..734efe8b7360 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -319,8 +319,13 @@ export function buildThreadListV2Items(input: { }> | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; - /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestStateByKey?: ReadonlyMap; + /** Per-row PR state reported up by visible rows ("env:threadId" keys). + updatedAt lets the partition hold a thread active when its activity is + newer than the merge/close. */ + readonly changeRequestStateByKey?: ReadonlyMap< + string, + { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } + >; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ @@ -380,7 +385,7 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; - const changeRequestState = + const changeRequest = input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; // Visibility parity with web: snooze outranks everything, including a // pin — a snoozed thread leaves the list until it wakes (or raises its @@ -405,7 +410,12 @@ export function buildThreadListV2Items(input: { } if ( supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + effectiveSettled(thread, { + now, + autoSettleAfterDays, + changeRequestState: changeRequest?.state ?? null, + changeRequestUpdatedAt: changeRequest?.updatedAt ?? null, + }) ) { settled.push(thread); } else { diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 601e29fa4447..e370e3a44b09 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -6,6 +6,9 @@ export type ThreadPr = NonNullable; export interface ThreadPrPresentation { readonly number: number; readonly state: ThreadPr["state"]; + /** Provider's last update to the PR (ISO); an upper bound on merge/close + time for the settled classification. Absent from older servers. */ + readonly updatedAt: string | null; readonly url: string; /** Compact pull request number label, e.g. "3774". */ readonly label: string; @@ -28,6 +31,7 @@ export function presentThreadPr( return { number: pr.number, state: pr.state, + updatedAt: pr.updatedAt ?? null, url: pr.url, label: String(pr.number), accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 5d95ea5f62f9..695130dc7860 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -719,6 +719,44 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status forwards the PR's updatedAt for the settled classification", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-updated-at"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-updated-at"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 15, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/15", + baseRefName: "main", + headRefName: "feature/status-updated-at", + state: "MERGED", + mergedAt: "2026-01-30T10:00:00Z", + updatedAt: "2026-01-30T10:05:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + // Clients compare thread activity against this timestamp to keep a + // thread active when work continued after the merge. + expect(status.pr?.state).toBe("merged"); + expect(status.pr?.updatedAt).toBe("2026-01-30T10:05:00.000Z"); + }), + ); + it.effect("status trims PR metadata returned by gh before publishing it", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -856,6 +894,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRef: "main", headRef: "feature/status-lowercase-state", state: "merged", + updatedAt: "2026-01-02T00:00:00.000Z", }); }), ); @@ -1119,6 +1158,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRef: "main", headRef: "statemachine", state: "open", + updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", @@ -1227,6 +1267,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRef: "main", headRef: "effect-atom", state: "open", + updatedAt: "2026-03-01T10:00:00.000Z", }); expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( false, @@ -1278,6 +1319,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRef: "main", headRef: "feature/status-merged-pr", state: "merged", + updatedAt: "2026-01-30T10:00:00.000Z", }); }), ); @@ -1357,6 +1399,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRef: "main", headRef: "feature/status-open-over-merged", state: "open", + updatedAt: "2026-01-30T10:00:00.000Z", }); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 553eda7bb9c3..05d1d93219c9 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -534,7 +534,9 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: string; headRef: string; state: "open" | "closed" | "merged"; + updatedAt?: string; } { + const updatedAt = Option.map(pr.updatedAt, DateTime.formatIso); return { number: pr.number, title: pr.title, @@ -542,6 +544,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: pr.baseRefName, headRef: pr.headRefName, state: pr.state, + ...(Option.isSome(updatedAt) ? { updatedAt: updatedAt.value } : {}), }; } diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index e057764683e5..95623519c3f5 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -385,9 +385,8 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { session: makeSession("running"), createdAt: NOW, }, - // A keep-active pin is also an override: real activity clears it - // back to neutral so auto-settle can apply again later. - readModel: makeReadModel("active"), + // A settled override must never hide a session coming alive. + readModel: makeReadModel("settled"), }); const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; expect(sessionEvents.map((event) => event.type)).toEqual([ @@ -397,8 +396,13 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ); - it.effect("clears a keep-active pin on real activity", () => + it.effect("keeps a keep-active pin through real activity", () => Effect.gen(function* () { + // The pin is the user's "this thread is NOT done" statement. Activity + // must not spend it: clearing it on a message/session start let the + // merged-PR auto-settle rule re-settle the thread the moment the same + // burst of work went quiet, defeating every un-settle. Only an + // explicit settle clears the pin. const turnResult = yield* decideOrchestrationCommand({ command: { type: "thread.turn.start", @@ -417,14 +421,24 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { readModel: makeReadModel("active"), }); const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; - // The pin exists to suppress AUTO-settle, not to survive real work: - // activity resets it to neutral, restoring the default lifecycle. expect(turnEvents.map((event) => event.type)).toEqual([ - "thread.unsettled", "thread.message-sent", "thread.turn-start-requested", ]); + const sessionResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make("cmd-active-session-set"), + threadId: ThreadId.make("thread-1"), + session: makeSession("running"), + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; + expect(sessionEvents.map((event) => event.type)).toEqual(["thread.session-set"]); + const activityResult = yield* decideOrchestrationCommand({ command: { type: "thread.activity.append", @@ -444,10 +458,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { readModel: makeReadModel("active"), }); const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult]; - expect(activityEvents.map((event) => event.type)).toEqual([ - "thread.unsettled", - "thread.activity-appended", - ]); + expect(activityEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a48bb29e154b..5517306b380f 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -983,13 +983,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - // Real activity resets ANY override: it wakes an explicitly settled - // thread, and it clears a keep-active pin back to neutral so the - // thread can auto-settle again after this burst of work goes stale. - // A snooze clears the same way — sending a message to a snoozed - // thread is the user re-engaging, so the return ticket is spent. + // Real activity wakes an explicitly settled thread — a settled + // override must never hide new work. The keep-active pin is the + // opposite statement ("this thread is NOT done, whatever the auto + // rules say") and is deliberately sticky: clearing it on activity + // let the merged-PR rule re-settle the thread minutes after every + // un-settle. Only an explicit settle spends the pin. + // A snooze clears on activity either way — sending a message to a + // snoozed thread is the user re-engaging, so the return ticket is + // spent. const lifecycleResetEvents: Array> = []; - if (targetThread.settledOverride !== null) { + if (targetThread.settledOverride === "settled") { lifecycleResetEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", @@ -1192,8 +1196,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // as snoozed, without spending the return ticket. const isSessionActivity = command.session.status === "starting" || command.session.status === "running"; - // Real activity resets ANY override (settled wakes, active unpins). - if (thread.settledOverride === null || !isSessionActivity) { + // Real activity wakes a settled override only; the keep-active pin is + // sticky (see thread.message.user.post) — a session merely starting or + // resuming must not erase the user's "not done" statement. + if (thread.settledOverride !== "settled" || !isSessionActivity) { return sessionSetEvent; } const unsettledEvent: Omit = { @@ -1369,8 +1375,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const wakesSettledThread = command.activity.kind === "approval.requested" || command.activity.kind === "user-input.requested"; - // Real activity resets ANY override (settled wakes, active unpins). - if (thread.settledOverride === null || !wakesSettledThread) { + // Real activity wakes a settled override only; the keep-active pin is + // sticky (see thread.message.user.post) and already keeps the thread + // visible, so there is nothing to reset. + if (thread.settledOverride !== "settled" || !wakesSettledThread) { return activityAppendedEvent; } const unsettledEvent: Omit = { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 73af865f250a..5f86810fb9bc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4156,9 +4156,11 @@ export function ChatViewContent(props: ChatViewProps) { now: `${nowMinute}:00.000Z`, autoSettleAfterDays, changeRequestState: activeThreadPr?.state ?? null, + changeRequestUpdatedAt: activeThreadPr?.updatedAt ?? null, }); }, [ activeThreadPr?.state, + activeThreadPr?.updatedAt, activeThreadShell, autoSettleAfterDays, nowMinute, @@ -6145,6 +6147,7 @@ export function ChatViewContent(props: ChatViewProps) { activeThreadTitle={activeThread.title} isServerThread={isServerThread} changeRequestState={activeThreadPr?.state ?? null} + changeRequestUpdatedAt={activeThreadPr?.updatedAt ?? null} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} activeProjectFaviconPath={activeProject?.faviconPath ?? null} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 35c510f9a71f..a73595e970b6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -698,7 +698,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; + onChangeRequestState: ( + threadKey: string, + state: "open" | "closed" | "merged" | null, + updatedAt: string | null, + ) => void; }) { const { isRenaming, @@ -847,10 +851,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; // Report the PR state up: the parent partitions rows with effectiveSettled, - // and a merged/closed PR auto-settles a thread — data only rows have. + // and a merged/closed PR auto-settles a thread — data only rows have. The + // PR's updatedAt rides along so the partition can hold a thread active + // when its activity is newer than the merge/close. + const prUpdatedAt = pr?.updatedAt ?? null; useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + onChangeRequestState(threadKey, prState, prUpdatedAt); + }, [onChangeRequestState, prState, prUpdatedAt, threadKey]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1765,19 +1772,27 @@ export default function Sidebar() { const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition. + // closed PR auto-settles its thread on the next partition. updatedAt rides + // along: the partition holds a thread active when its activity is newer + // than the merge/close. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap + ReadonlyMap< + string, + { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } + > >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { + (threadKey: string, state: "open" | "closed" | "merged" | null, updatedAt: string | null) => { setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; + const existing = current.get(threadKey) ?? null; + if ((existing?.state ?? null) === state && (existing?.updatedAt ?? null) === updatedAt) { + return current; + } const next = new Map(current); if (state === null) { next.delete(threadKey); } else { - next.set(threadKey, state); + next.set(threadKey, { state, updatedAt }); } return next; }); @@ -1899,7 +1914,7 @@ export default function Sidebar() { const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + const changeRequest = changeRequestStateByKey.get(threadKey) ?? null; // Snooze outranks everything, including a pin: "hide until Tuesday" // temporarily suspends "keep on top". The pin survives underneath — // and so does its pinOrderKey, so on wake the thread reappears at @@ -1916,7 +1931,12 @@ export default function Sidebar() { pinned.push(thread); } else if ( supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + effectiveSettled(thread, { + now, + autoSettleAfterDays, + changeRequestState: changeRequest?.state ?? null, + changeRequestUpdatedAt: changeRequest?.updatedAt ?? null, + }) ) { settled.push(thread); } else { diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 5e0c09cacffa..b6735e57ec2e 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -48,6 +48,8 @@ interface ChatHeaderProps { isServerThread: boolean; /** PR state feeding the settled classification, resolved by ChatView. */ changeRequestState: ChangeRequestStateLike | null; + /** The PR's last-updated time; holds post-merge-activity threads active. */ + changeRequestUpdatedAt: string | null; activeProjectName: string | undefined; activeProjectCwd: string | null; activeProjectFaviconPath: string | null; @@ -101,6 +103,7 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadTitle, isServerThread, changeRequestState, + changeRequestUpdatedAt, activeProjectName, activeProjectCwd, activeProjectFaviconPath, @@ -176,6 +179,7 @@ export const ChatHeader = memo(function ChatHeader({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, changeRequestState, + changeRequestUpdatedAt, onStartRename: startRename, }); const titleButtonRef = useRef(null); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 85ffde776b43..8187ed1f51a3 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -62,9 +62,12 @@ export function useThreadActionMenu(input: { readonly projectCwd: string | null; /** PR state feeding auto-settle classification, as resolved by the caller. */ readonly changeRequestState: ChangeRequestStateLike | null; + /** The PR's last-updated time; holds post-merge-activity threads active. */ + readonly changeRequestUpdatedAt: string | null; readonly onStartRename: () => void; }) { - const { threadRef, projectCwd, changeRequestState, onStartRename } = input; + const { threadRef, projectCwd, changeRequestState, changeRequestUpdatedAt, onStartRename } = + input; const { settleThread, unsettleThread, @@ -127,6 +130,7 @@ export function useThreadActionMenu(input: { now: `${now.toISOString().slice(0, 16)}:00.000Z`, autoSettleAfterDays, changeRequestState, + changeRequestUpdatedAt, }), isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), @@ -275,6 +279,7 @@ export function useThreadActionMenu(input: { [ autoSettleAfterDays, changeRequestState, + changeRequestUpdatedAt, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, diff --git a/docs/operations/turbo-changelog.md b/docs/operations/turbo-changelog.md index 1941aa74d596..40ea997bbdda 100644 --- a/docs/operations/turbo-changelog.md +++ b/docs/operations/turbo-changelog.md @@ -8,6 +8,14 @@ per-commit — the ingestion PR entry records the upstream range instead. ## Unreleased — on `turbo`, not yet in a shipped build +- **Un-settling a thread now sticks, and a merged PR no longer buries live follow-up work.** The + keep-active pin survives messages, session starts, and approval/input requests — only an + explicit settle spends it (activity still wakes explicitly _settled_ threads). And a merged or + closed PR insta-settles a thread only when it went quiet at completion; newer activity defers to + the ordinary inactivity rule, fed by the PR's `updatedAt` now carried on the VCS status + contract. New seam `settled-lifecycle-sticky-pin`; upstream candidate referencing + pingdotgg/t3code#5575 / #5643. Ported from fork-main PR #61. + Wave 1 of the speed plan ([`.plans/23-turbo-performance-audit.md`](../../.plans/23-turbo-performance-audit.md)): seven items across four surfaces. Every one keeps the behavior it found — the tests that pin the old orderings and the old wire bytes are part of the wave. diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index a7dd4b1eab83..05f3d26bcf62 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -178,6 +178,67 @@ describe("effectiveSettled", () => { } }); + it("holds a completed-PR thread active while activity is newer than the PR", () => { + // Work continuing after the merge — follow-up fixes in the same worktree — + // must not vanish the moment each burst ends. With the PR's timestamp + // available, instant settle applies only when the thread went quiet at or + // before completion; newer activity falls back to the inactivity rule. + const completedAt = "2026-04-08T00:00:00.000Z"; + for (const changeRequestState of ["merged", "closed"] as const) { + const activeAfterMerge = makeShell({ activityAt: FRESH }); + expect( + effectiveSettled(activeAfterMerge, { + now: NOW, + autoSettleAfterDays: 3, + changeRequestState, + changeRequestUpdatedAt: completedAt, + }), + ).toBe(false); + + // Post-merge activity that then goes stale settles via inactivity — + // a completed PR no longer blocks that path the way an open one does. + const staleAfterMerge = makeShell({ activityAt: STALE }); + expect( + effectiveSettled(staleAfterMerge, { + now: NOW, + autoSettleAfterDays: 3, + changeRequestState, + changeRequestUpdatedAt: "2026-04-05T00:00:00.000Z", + }), + ).toBe(true); + // ...but never when inactivity auto-settle is disabled. + expect( + effectiveSettled(staleAfterMerge, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState, + changeRequestUpdatedAt: "2026-04-05T00:00:00.000Z", + }), + ).toBe(false); + + // Quiet since before completion: instant settle, as always. + const quietSinceMerge = makeShell({ activityAt: "2026-04-07T00:00:00.000Z" }); + expect( + effectiveSettled(quietSinceMerge, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState, + changeRequestUpdatedAt: completedAt, + }), + ).toBe(true); + + // No timestamp (old callers / providers without one): the original + // instant behavior holds even with fresh activity. + expect( + effectiveSettled(activeAfterMerge, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState, + }), + ).toBe(true); + } + }); + it("never auto-settles a stale thread with an open change request", () => { const stale = makeShell({ activityAt: STALE }); expect( diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 595b1303bea5..213e227385bf 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -233,6 +233,15 @@ export function effectiveSettled( readonly now: string; readonly autoSettleAfterDays: number | null; readonly changeRequestState?: ChangeRequestStateLike | null; + /** + * The change request's last-updated time (ISO). An upper bound on when a + * merged/closed PR completed: instant PR auto-settle applies only when + * the thread has no activity NEWER than this — work continuing after the + * merge falls back to the inactivity rule instead of vanishing mid-burst. + * Absent (old callers / no data): every merged/closed PR settles + * instantly, the pre-gate behavior. + */ + readonly changeRequestUpdatedAt?: string | null; }, ): boolean { // Blocked work must remain visible even when a user explicitly settled it. @@ -258,17 +267,30 @@ export function effectiveSettled( // "active" is the explicit keep-active pin: it suppresses auto-settle // until real activity clears it server-side. if (shell.settledOverride === "active") return false; + const lastActivityAt = threadLastActivityAt(shell); if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { - return true; + // Instant settle is for threads that went quiet at the merge/close. + // Activity newer than the PR's last update is the user still working in + // the thread AFTER completion — follow-up fixes, a new task on the same + // branch — and insta-settling would hide the thread the moment each + // burst ends. Such threads fall through to the inactivity rule (an open + // PR no longer blocks it: this one is done). Without a timestamp the + // comparison is unknowable and the original instant behavior applies. + const completedAtMs = Date.parse(options.changeRequestUpdatedAt ?? ""); + const activityAfterCompletion = + !Number.isNaN(completedAtMs) && + lastActivityAt !== null && + Date.parse(lastActivityAt) > completedAtMs; + if (!activityAfterCompletion) return true; + } else if (options.changeRequestState === "open") { + // An open PR is unfinished business regardless of how long the thread + // has been quiet: review can take days, and hiding the thread would + // bury the work waiting on it. Only merge/close (above) or an explicit + // user settle resolves it. + return false; } - // An open PR is unfinished business regardless of how long the thread has - // been quiet: review can take days, and hiding the thread would bury the - // work waiting on it. Only merge/close (above) or an explicit user settle - // resolves it. - if (options.changeRequestState === "open") return false; if (options.autoSettleAfterDays === null) return false; - const lastActivityAt = threadLastActivityAt(shell); if (lastActivityAt === null) return false; // threadLastActivityAt only returns candidates whose Date.parse beat diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 2e0552740a6c..63aec4800de3 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -197,6 +197,11 @@ const VcsStatusChangeRequest = Schema.Struct({ baseRef: TrimmedNonEmptyStringSchema, headRef: TrimmedNonEmptyStringSchema, state: VcsStatusChangeRequestState, + // ISO timestamp of the provider's last update to the PR. For merged/closed + // PRs this is an upper bound on the completion time; clients use it to + // keep a thread active when work continued after the merge (see + // effectiveSettled). Optional for version skew: older servers omit it. + updatedAt: Schema.optional(Schema.String), }); const VcsStatusLocalShape = { From 0173047602c79cc886d3d52daa295c3a5413ad71 Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Mon, 10 Aug 2026 11:22:58 -0400 Subject: [PATCH 2/3] test(turbo): track the settled-lifecycle-sticky-pin seam in the manifest test Co-Authored-By: Claude Fable 5 --- scripts/turbo-customization-manifest.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/turbo-customization-manifest.test.ts b/scripts/turbo-customization-manifest.test.ts index 285ddc981c57..06e74274a4de 100644 --- a/scripts/turbo-customization-manifest.test.ts +++ b/scripts/turbo-customization-manifest.test.ts @@ -158,6 +158,7 @@ it("verifies the checked-in Turbo manifest and tracks the implemented multi-chat "relay-auth-and-link-memos", "relay-policy", "relay-request-budget-and-clerk-client", + "settled-lifecycle-sticky-pin", "shared-sha256-base64url", "sqlite-fast-mode-pragma", "streaming-flag-cleared-on-turn-settle", From 83eee758e625cdd3254e9f85ab74257e49b0b9b3 Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Mon, 10 Aug 2026 11:32:22 -0400 Subject: [PATCH 3/3] chore(infra): format headless-vps.md Formatting was broken at the tip of turbo (89013132) and fails the Check job for every PR; formatted here to unblock. Co-Authored-By: Claude Fable 5 --- infra/headless-vps.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/infra/headless-vps.md b/infra/headless-vps.md index 57cfe9bd8da6..ab0a0db099da 100644 --- a/infra/headless-vps.md +++ b/infra/headless-vps.md @@ -15,13 +15,13 @@ systemd unit, see [Background service](../docs/user/background-service.md). ## Mental model -| Piece | Lives where | -| --- | --- | -| T3 Code server | The VPS | -| Provider CLIs (`claude`, `codex`, …) and their logins | The VPS | -| Project directories and git checkouts | The VPS | -| Thread history and environment settings | The VPS (`~/.t3` by default) | -| Desktop / web / mobile UI | Your laptop or phone | +| Piece | Lives where | +| ----------------------------------------------------- | ---------------------------- | +| T3 Code server | The VPS | +| Provider CLIs (`claude`, `codex`, …) and their logins | The VPS | +| Project directories and git checkouts | The VPS | +| Thread history and environment settings | The VPS (`~/.t3` by default) | +| Desktop / web / mobile UI | Your laptop or phone | If a provider is only authenticated on your laptop, sessions on the VPS will fail. Log in on the machine that runs the server. @@ -42,11 +42,11 @@ parallel-agent loads need more. ## Choose how clients reach the VPS -| Method | Best when | Inbound ports on the VPS | -| --- | --- | --- | -| **T3 Connect** (recommended) | You want phone + hosted web + desktop without opening the server to the internet | None on the VPS (outbound managed tunnel only; clients reach a tunnel hostname, not your public IP) | -| **Tailscale** (+ `t3 serve` / `t3 pair`) | You already mesh devices on a tailnet and want private HTTPS | None public; tailnet only | -| **Desktop SSH** | You only use the desktop app and already SSH to the host | SSH only | +| Method | Best when | Inbound ports on the VPS | +| ---------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| **T3 Connect** (recommended) | You want phone + hosted web + desktop without opening the server to the internet | None on the VPS (outbound managed tunnel only; clients reach a tunnel hostname, not your public IP) | +| **Tailscale** (+ `t3 serve` / `t3 pair`) | You already mesh devices on a tailnet and want private HTTPS | None public; tailnet only | +| **Desktop SSH** | You only use the desktop app and already SSH to the host | SSH only | Do not bind the T3 server to `0.0.0.0` on a public VPS IP unless you understand the exposure. Prefer Connect, Tailscale, or SSH. @@ -287,15 +287,15 @@ runs the headless steps in this document against that stack. ## Troubleshooting -| Symptom | What to check | -| --- | --- | -| Environment never appears after Connect | Same Connect account on client and VPS; `connect status` shows provisioned link; a T3 server process is running with Connect intent set | -| Provider fails at session start | Provider CLI on VPS `PATH`; login was run **on the VPS**; binary path in Settings if needed | -| `node: command not found` over SSH | Non-interactive `PATH` / version-manager default on the VPS | -| Hosted web cannot connect | Backend must be HTTPS/WSS (Connect or Tailscale Serve). Plain `http://` LAN URLs are blocked from HTTPS pages | -| Works until you disconnect SSH | Background service not installed; run `service install` so the process outlives the session | -| “Invalid pairing token” while using Connect | Connect does not use pairing tokens; sign in and pick the environment instead | -| `connect` complains about missing config | CLI/build lacks Connect public values (common on unconfigured source builds); use a release built for your stack | +| Symptom | What to check | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Environment never appears after Connect | Same Connect account on client and VPS; `connect status` shows provisioned link; a T3 server process is running with Connect intent set | +| Provider fails at session start | Provider CLI on VPS `PATH`; login was run **on the VPS**; binary path in Settings if needed | +| `node: command not found` over SSH | Non-interactive `PATH` / version-manager default on the VPS | +| Hosted web cannot connect | Backend must be HTTPS/WSS (Connect or Tailscale Serve). Plain `http://` LAN URLs are blocked from HTTPS pages | +| Works until you disconnect SSH | Background service not installed; run `service install` so the process outlives the session | +| “Invalid pairing token” while using Connect | Connect does not use pairing tokens; sign in and pick the environment instead | +| `connect` complains about missing config | CLI/build lacks Connect public values (common on unconfigured source builds); use a release built for your stack | ---