authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-18 21:29:33-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-18 18:29:33-07:00
logf21b47e52d988839a4e488d9fa98344891a69b7f
treeb25e6785c91b24921c5630fec59ed80afb02666c
parentefcf7d1ac03e784bdb26843d61ae0ff81c03cec6
signature Signed by PGP key B5690EEEBB952194

fix(threads): a merged PR settles its thread only once (#7454)


17 files changed, 331 insertions(+), 75 deletions(-)

apps/mobile/src/features/home/HomeScreen.tsx+16-9
......@@ -12,6 +12,7 @@ import {
1212 type EnvironmentThreadSearchMatch,
1313} from "@t3tools/client-runtime/state/thread-search";
1414import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
1516import type {
1617 EnvironmentId,
1718 SidebarProjectGroupingMode,
......@@ -488,18 +489,24 @@ export function HomeScreen(props: HomeScreenProps) {
488489 // optimistic holds.
489490 // PR states stream in per-row. The next partition applies the configured
490491 // merge rule and the always-on close rule, matching web.
491 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
492 ReadonlyMap<string, "open" | "closed" | "merged">
492 const [changeRequestByKey, setChangeRequestByKey] = useState<
493 ReadonlyMap<string, ChangeRequestSettleSource>
493494 >(() => new Map());
494495 const handleChangeRequestState = useCallback(
495 (threadKey: string, state: "open" | "closed" | "merged" | null) => {
496 setChangeRequestStateByKey((current) => {
497 if ((current.get(threadKey) ?? null) === state) return current;
496 (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
497 setChangeRequestByKey((current) => {
498 const existing = current.get(threadKey) ?? null;
499 if (
500 (existing?.state ?? null) === (changeRequest?.state ?? null) &&
501 (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
502 ) {
503 return current;
504 }
498505 const next = new Map(current);
499 if (state === null) {
506 if (changeRequest === null) {
500507 next.delete(threadKey);
501508 } else {
502 next.set(threadKey, state);
509 next.set(threadKey, changeRequest);
503510 }
504511 return next;
505512 });
......@@ -667,7 +674,7 @@ export function HomeScreen(props: HomeScreenProps) {
667674 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
668675 searchQuery: props.searchQuery,
669676 matchedThreadKeys,
670 changeRequestStateByKey,
677 changeRequestByKey,
671678 autoSettleOnMerge,
672679 settlementEnvironmentIds,
673680 snoozeEnvironmentIds,
......@@ -679,7 +686,7 @@ export function HomeScreen(props: HomeScreenProps) {
679686 selectedThreadKey: null,
680687 });
681688 }, [
682 changeRequestStateByKey,
689 changeRequestByKey,
683690 autoSettleOnMerge,
684691 nowMinute,
685692 snoozeWakeTick,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+16-9
......@@ -13,6 +13,7 @@ import { useAtomValue } from "@effect/atom-react";
1313import { AsyncResult } from "effect/unstable/reactivity";
1414import type { EnvironmentId } from "@t3tools/contracts";
1515import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
16import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
1617import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
1718import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
1819import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
......@@ -420,18 +421,24 @@ function ThreadNavigationSidebarPane(
420421 // (HomeScreen.tsx): flat creation-order card block + settled recency tail.
421422 // PR states stream in per-row. The next partition applies the configured
422423 // merge rule and the always-on close rule.
423 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
424 ReadonlyMap<string, "open" | "closed" | "merged">
424 const [changeRequestByKey, setChangeRequestByKey] = useState<
425 ReadonlyMap<string, ChangeRequestSettleSource>
425426 >(() => new Map());
426427 const handleChangeRequestState = useCallback(
427 (threadKey: string, state: "open" | "closed" | "merged" | null) => {
428 setChangeRequestStateByKey((current) => {
429 if ((current.get(threadKey) ?? null) === state) return current;
428 (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
429 setChangeRequestByKey((current) => {
430 const existing = current.get(threadKey) ?? null;
431 if (
432 (existing?.state ?? null) === (changeRequest?.state ?? null) &&
433 (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
434 ) {
435 return current;
436 }
430437 const next = new Map(current);
431 if (state === null) {
438 if (changeRequest === null) {
432439 next.delete(threadKey);
433440 } else {
434 next.set(threadKey, state);
441 next.set(threadKey, changeRequest);
435442 }
436443 return next;
437444 });
......@@ -552,7 +559,7 @@ function ThreadNavigationSidebarPane(
552559 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
553560 searchQuery: props.searchQuery,
554561 matchedThreadKeys,
555 changeRequestStateByKey,
562 changeRequestByKey,
556563 autoSettleOnMerge,
557564 settlementEnvironmentIds,
558565 snoozeEnvironmentIds,
......@@ -564,7 +571,7 @@ function ThreadNavigationSidebarPane(
564571 selectedThreadKey: props.selectedThreadKey ?? null,
565572 });
566573 }, [
567 changeRequestStateByKey,
574 changeRequestByKey,
568575 autoSettleOnMerge,
569576 nowMinute,
570577 snoozeWakeTick,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+14-6
......@@ -3,7 +3,11 @@ import type {
33 EnvironmentThreadShell,
44} from "@t3tools/client-runtime/state/shell";
55import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
6import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
6import {
7 canSnooze,
8 resolveSnoozePresets,
9 type ChangeRequestSettleSource,
10} from "@t3tools/client-runtime/state/thread-settled";
711import type { MenuAction } from "@react-native-menu/menu";
812import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
913import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
......@@ -365,11 +369,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
365369 readonly canMovePinnedDown?: boolean;
366370 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
367371 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
368 /** Reports this row's live PR state for the partition's merge and close
369 rules. Mirrors web's onChangeRequestState. */
372 /** Reports this row's live PR (state + last activity) for the partition's
373 merge and close rules. Mirrors web's onChangeRequestState. */
370374 readonly onChangeRequestState?: (
371375 threadKey: string,
372 state: "open" | "closed" | "merged" | null,
376 changeRequest: ChangeRequestSettleSource | null,
373377 ) => void;
374378 readonly projectCwd?: string | null;
375379 readonly searchMatch?: EnvironmentThreadSearchMatch;
......@@ -400,10 +404,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
400404
401405 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
402406 const prState = pr?.state ?? null;
407 const prUpdatedAt = pr?.updatedAt ?? null;
403408 const threadKey = `${thread.environmentId}:${thread.id}`;
404409 useEffect(() => {
405 onChangeRequestState?.(threadKey, prState);
406 }, [onChangeRequestState, prState, threadKey]);
410 onChangeRequestState?.(
411 threadKey,
412 prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
413 );
414 }, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
407415
408416 const screenColor = useThemeColor("--color-screen");
409417 const drawerColor = useThemeColor("--color-drawer");
apps/mobile/src/features/threads/threadListV2.test.ts+3-1
......@@ -269,7 +269,9 @@ describe("buildThreadListV2Items", () => {
269269 threads: [merged],
270270 environmentId: null,
271271 searchQuery: "",
272 changeRequestStateByKey: new Map([[`${environmentId}:${merged.id}`, "merged"]]),
272 changeRequestByKey: new Map([
273 [`${environmentId}:${merged.id}`, { state: "merged" as const }],
274 ]),
273275 autoSettleOnMerge: false,
274276 now: NOW,
275277 });
apps/mobile/src/features/threads/threadListV2.ts+9-6
......@@ -6,7 +6,10 @@ import {
66 resolveSnoozePresets,
77 snoozeWakeLabel,
88} from "@t3tools/client-runtime/state/thread-settled";
9import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
9import type {
10 ChangeRequestSettleSource,
11 SnoozePreset,
12} from "@t3tools/client-runtime/state/thread-settled";
1013import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1114import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
1215import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
......@@ -318,8 +321,8 @@ export function buildThreadListV2Items(input: {
318321 }> | null;
319322 readonly searchQuery: string;
320323 readonly matchedThreadKeys?: ReadonlySet<string>;
321 /** Per-row PR state reported up by visible rows ("env:threadId" keys). */
322 readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">;
324 /** Per-row PR reported up by visible rows ("env:threadId" keys). */
325 readonly changeRequestByKey?: ReadonlyMap<string, ChangeRequestSettleSource>;
323326 /** Environments whose server supports thread.settle/unsettle. Threads on
324327 other environments never classify as settled — the user could neither
325328 un-settle nor pin them. Absent = no gating (tests). */
......@@ -381,8 +384,8 @@ export function buildThreadListV2Items(input: {
381384 }
382385 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
383386 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
384 const changeRequestState =
385 input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
387 const changeRequest =
388 input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
386389 // Visibility parity with web: snooze outranks everything, including a
387390 // pin — a snoozed thread leaves the list until it wakes (or raises its
388391 // hand). The pin (and its pinOrderKey) survives underneath, so a woken
......@@ -410,7 +413,7 @@ export function buildThreadListV2Items(input: {
410413 now,
411414 autoSettleAfterDays,
412415 autoSettleOnMerge,
413 changeRequestState,
416 changeRequest,
414417 })
415418 ) {
416419 settled.push(thread);
apps/mobile/src/state/thread-pr-presentation.ts+3
......@@ -6,6 +6,8 @@ export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;
66export interface ThreadPrPresentation {
77 readonly number: number;
88 readonly state: ThreadPr["state"];
9 /** Provider-side last activity, bounding when a terminal state landed. */
10 readonly updatedAt: string | null;
911 readonly url: string;
1012 /** Compact pull request number label, e.g. "3774". */
1113 readonly label: string;
......@@ -28,6 +30,7 @@ export function presentThreadPr(
2830 return {
2931 number: pr.number,
3032 state: pr.state,
33 updatedAt: pr.updatedAt ?? null,
3134 url: pr.url,
3235 label: String(pr.number),
3336 accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`,
apps/server/src/git/GitManager.test.ts+9
......@@ -717,6 +717,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
717717 baseRef: "main",
718718 headRef: "feature/status-open-pr",
719719 state: "open",
720 updatedAt: null,
720721 });
721722 }),
722723 );
......@@ -756,6 +757,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
756757 baseRef: "main",
757758 headRef: "feature/status-trimmed-pr",
758759 state: "open",
760 updatedAt: null,
759761 });
760762 }),
761763 );
......@@ -808,6 +810,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
808810 baseRef: "main",
809811 headRef: "feature/status-valid-pr-entry",
810812 state: "open",
813 updatedAt: null,
811814 });
812815 }),
813816 );
......@@ -858,6 +861,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
858861 baseRef: "main",
859862 headRef: "feature/status-lowercase-state",
860863 state: "merged",
864 updatedAt: "2026-01-02T00:00:00.000Z",
861865 });
862866 }),
863867 );
......@@ -1121,6 +1125,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
11211125 baseRef: "main",
11221126 headRef: "statemachine",
11231127 state: "open",
1128 updatedAt: "2026-03-10T07:00:00.000Z",
11241129 });
11251130 expect(ghCalls).toContain(
11261131 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
......@@ -1186,6 +1191,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
11861191 baseRef: "main",
11871192 headRef: "main",
11881193 state: "open",
1194 updatedAt: "2026-03-10T07:00:00.000Z",
11891195 });
11901196 expect(ghCalls).toContain(
11911197 "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
......@@ -1294,6 +1300,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
12941300 baseRef: "main",
12951301 headRef: "effect-atom",
12961302 state: "open",
1303 updatedAt: "2026-03-01T10:00:00.000Z",
12971304 });
12981305 expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(
12991306 false,
......@@ -1345,6 +1352,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
13451352 baseRef: "main",
13461353 headRef: "feature/status-merged-pr",
13471354 state: "merged",
1355 updatedAt: "2026-01-30T10:00:00.000Z",
13481356 });
13491357 }),
13501358 );
......@@ -1461,6 +1469,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
14611469 baseRef: "main",
14621470 headRef: "feature/status-open-over-merged",
14631471 state: "open",
1472 updatedAt: "2026-01-30T10:00:00.000Z",
14641473 });
14651474 }),
14661475 );
apps/server/src/git/GitManager.ts+5
......@@ -538,6 +538,7 @@ function toStatusPr(pr: PullRequestInfo): {
538538 baseRef: string;
539539 headRef: string;
540540 state: "open" | "closed" | "merged";
541 updatedAt: string | null;
541542} {
542543 return {
543544 number: pr.number,
......@@ -546,6 +547,10 @@ function toStatusPr(pr: PullRequestInfo): {
546547 baseRef: pr.baseRefName,
547548 headRef: pr.headRefName,
548549 state: pr.state,
550 updatedAt: Option.match(pr.updatedAt, {
551 onNone: () => null,
552 onSome: (updatedAt) => DateTime.formatIso(updatedAt),
553 }),
549554 };
550555}
551556
apps/web/src/components/ChatView.tsx+25-5
......@@ -4151,6 +4151,18 @@ function ChatViewContent(props: ChatViewProps) {
41514151 }, [activeThreadPr, openThreadPullRequest]);
41524152 const pullRequestSurfaceAvailable =
41534153 supportsPullRequests && activeThreadPr !== null && threadRepository !== null;
4154 // Primitive slice of the displayed PR for the settle-rule memos below:
4155 // resolveDisplayedThreadPr returns a fresh object every render, so memoize
4156 // on the fields the rules read instead of the object identity.
4157 const activeThreadPrState = activeThreadPr?.state ?? null;
4158 const activeThreadPrUpdatedAt = activeThreadPr?.updatedAt ?? null;
4159 const activeThreadChangeRequest = useMemo(
4160 () =>
4161 activeThreadPrState === null
4162 ? null
4163 : { state: activeThreadPrState, updatedAt: activeThreadPrUpdatedAt },
4164 [activeThreadPrState, activeThreadPrUpdatedAt],
4165 );
41544166 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
41554167 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
41564168 const nowMinute = useNowMinute();
......@@ -4186,7 +4198,14 @@ function ChatViewContent(props: ChatViewProps) {
41864198 );
41874199 const activeThreadWokeVisible = useMemo(() => {
41884200 if (activeThreadWokeAt === null) return false;
4189 if (changeRequestAutoSettles(activeThreadPr?.state, autoSettleOnMerge)) return false;
4201 if (
4202 changeRequestAutoSettles(activeThreadChangeRequest, {
4203 autoSettleOnMerge,
4204 thread: activeThreadShell,
4205 })
4206 ) {
4207 return false;
4208 }
41904209 const wokeAtMs = Date.parse(activeThreadWokeAt);
41914210 if (Number.isNaN(wokeAtMs)) return false;
41924211 // Having the thread open counts as a visit at completedAt (the effect
......@@ -4206,7 +4225,8 @@ function ChatViewContent(props: ChatViewProps) {
42064225 }, [
42074226 activeLatestTurn?.completedAt,
42084227 activeThreadLastVisitedAt,
4209 activeThreadPr?.state,
4228 activeThreadChangeRequest,
4229 activeThreadShell,
42104230 activeThreadWokeAt,
42114231 autoSettleOnMerge,
42124232 ]);
......@@ -4216,10 +4236,10 @@ function ChatViewContent(props: ChatViewProps) {
42164236 now: `${nowMinute}:00.000Z`,
42174237 autoSettleAfterDays,
42184238 autoSettleOnMerge,
4219 changeRequestState: activeThreadPr?.state ?? null,
4239 changeRequest: activeThreadChangeRequest,
42204240 });
42214241 }, [
4222 activeThreadPr?.state,
4242 activeThreadChangeRequest,
42234243 activeThreadShell,
42244244 autoSettleAfterDays,
42254245 autoSettleOnMerge,
......@@ -6245,7 +6265,7 @@ function ChatViewContent(props: ChatViewProps) {
62456265 {...(routeKind === "draft" && draftId ? { draftId } : {})}
62466266 activeThreadTitle={activeThread.title}
62476267 isServerThread={isServerThread}
6248 changeRequestState={activeThreadPr?.state ?? null}
6268 changeRequest={activeThreadChangeRequest}
62496269 activeProjectName={activeProject?.title}
62506270 activeProjectCwd={activeProject?.workspaceRoot ?? null}
62516271 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
apps/web/src/components/Sidebar.tsx+7-5
......@@ -801,7 +801,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
801801 snapshot: changeRequestSnapshot,
802802 retainTerminalOnBranchMismatch,
803803 });
804 const prState = pr?.state ?? null;
805804
806805 // Same semantics as the legacy sidebar (never-visited counts as read):
807806 // switching sidebars must not light up every historical thread as unread.
......@@ -819,7 +818,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
819818 const isWoke =
820819 wokeAtDate !== null &&
821820 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
822 !changeRequestAutoSettles(prState, props.autoSettleOnMerge);
821 !changeRequestAutoSettles(pr, {
822 autoSettleOnMerge: props.autoSettleOnMerge,
823 thread,
824 });
823825 // In-flight rows (working, or waiting on approval/input) fade as a whole:
824826 // there is nothing for the user to do yet, so prominence is reserved for
825827 // rows that need a human — done (unread), read-but-unsettled, failed, and
......@@ -2024,9 +2026,9 @@ export default function Sidebar() {
20242026 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
20252027 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
20262028 const snapshot = changeRequestSnapshotByKey.get(threadKey);
2027 const changeRequestState =
2029 const changeRequest =
20282030 snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch)
2029 ? snapshot.pr.state
2031 ? snapshot.pr
20302032 : null;
20312033 // Snooze outranks everything, including a pin: "hide until Tuesday"
20322034 // temporarily suspends "keep on top". The pin survives underneath —
......@@ -2048,7 +2050,7 @@ export default function Sidebar() {
20482050 now,
20492051 autoSettleAfterDays,
20502052 autoSettleOnMerge,
2051 changeRequestState,
2053 changeRequest,
20522054 })
20532055 ) {
20542056 settled.push(thread);
apps/web/src/components/ThreadStatusIndicators.test.ts+1-1
......@@ -426,7 +426,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
426426 effectiveSettled(shell, {
427427 now: "2026-04-10T00:00:00.000Z",
428428 autoSettleAfterDays: null,
429 changeRequestState: displayed?.state ?? null,
429 changeRequest: displayed,
430430 }),
431431 ).toBe(true);
432432 });
apps/web/src/components/ThreadStatusIndicators.tsx+1
......@@ -169,6 +169,7 @@ export function threadChangeRequestSnapshotsEqual(
169169 left.pr.baseRef === right.pr.baseRef &&
170170 left.pr.headRef === right.pr.headRef &&
171171 left.pr.state === right.pr.state &&
172 (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) &&
172173 sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider)
173174 );
174175}
apps/web/src/components/chat/ChatHeader.tsx+5-5
......@@ -10,7 +10,7 @@ import {
1010 isAtomCommandInterrupted,
1111 squashAtomCommandFailure,
1212} from "@t3tools/client-runtime/state/runtime";
13import type { ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled";
13import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
1414import { ChevronDownIcon } from "lucide-react";
1515import {
1616 memo,
......@@ -51,8 +51,8 @@ interface ChatHeaderProps {
5151 activeThreadTitle: string;
5252 /** Drafts have no server thread yet, so the title carries no action menu. */
5353 isServerThread: boolean;
54 /** PR state feeding the settled classification, resolved by ChatView. */
55 changeRequestState: ChangeRequestStateLike | null;
54 /** PR feeding the settled classification, resolved by ChatView. */
55 changeRequest: ChangeRequestSettleSource | null;
5656 activeProjectName: string | undefined;
5757 activeProjectCwd: string | null;
5858 activeProjectFaviconPath: string | null;
......@@ -113,7 +113,7 @@ export const ChatHeader = memo(function ChatHeader({
113113 draftId,
114114 activeThreadTitle,
115115 isServerThread,
116 changeRequestState,
116 changeRequest,
117117 activeProjectName,
118118 activeProjectCwd,
119119 activeProjectFaviconPath,
......@@ -191,7 +191,7 @@ export const ChatHeader = memo(function ChatHeader({
191191 const { openMenu } = useThreadActionMenu({
192192 threadRef: isServerThread ? activeThreadRef : null,
193193 projectCwd: activeProjectCwd,
194 changeRequestState,
194 changeRequest,
195195 onStartRename: startRename,
196196 });
197197 const titleButtonRef = useRef<HTMLButtonElement | null>(null);
apps/web/src/hooks/useThreadActionMenu.ts+6-6
......@@ -9,7 +9,7 @@ import {
99 canSnooze,
1010 effectiveSettled,
1111 effectiveSnoozed,
12 type ChangeRequestStateLike,
12 type ChangeRequestSettleSource,
1313} from "@t3tools/client-runtime/state/thread-settled";
1414import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
1515import { useCallback } from "react";
......@@ -60,11 +60,11 @@ export function useThreadActionMenu(input: {
6060 readonly threadRef: ScopedThreadRef | null;
6161 /** Fallback for "Copy path" when the thread has no worktree. */
6262 readonly projectCwd: string | null;
63 /** PR state feeding auto-settle classification, as resolved by the caller. */
64 readonly changeRequestState: ChangeRequestStateLike | null;
63 /** PR feeding auto-settle classification, as resolved by the caller. */
64 readonly changeRequest: ChangeRequestSettleSource | null;
6565 readonly onStartRename: () => void;
6666}) {
67 const { threadRef, projectCwd, changeRequestState, onStartRename } = input;
67 const { threadRef, projectCwd, changeRequest, onStartRename } = input;
6868 const {
6969 settleThread,
7070 unsettleThread,
......@@ -136,7 +136,7 @@ export function useThreadActionMenu(input: {
136136 now: `${now.toISOString().slice(0, 16)}:00.000Z`,
137137 autoSettleAfterDays,
138138 autoSettleOnMerge,
139 changeRequestState,
139 changeRequest,
140140 }),
141141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
142142 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
......@@ -312,7 +312,7 @@ export function useThreadActionMenu(input: {
312312 archiveThread,
313313 autoSettleAfterDays,
314314 autoSettleOnMerge,
315 changeRequestState,
315 changeRequest,
316316 confirmThreadArchive,
317317 confirmThreadDelete,
318318 copyBranchToClipboard,
packages/client-runtime/src/state/threadSettled.test.ts+126-12
......@@ -28,7 +28,97 @@ describe("changeRequestAutoSettles", () => {
2828 ["closed", false, true],
2929 [null, false, false],
3030 ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => {
31 expect(changeRequestAutoSettles(state, autoSettleOnMerge)).toBe(expected);
31 expect(changeRequestAutoSettles(state === null ? null : { state }, { autoSettleOnMerge })).toBe(
32 expected,
33 );
34 });
35
36 const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z";
37 const idleThread = {
38 createdAt: THREAD_CREATED_AT,
39 latestUserMessageAt: null,
40 latestTurn: null,
41 };
42
43 it("ignores a terminal change request last touched before the thread existed", () => {
44 for (const state of ["merged", "closed"] as const) {
45 expect(
46 changeRequestAutoSettles(
47 { state, updatedAt: "2026-03-31T23:59:59.999Z" },
48 { thread: idleThread },
49 ),
50 ).toBe(false);
51 }
52 });
53
54 it("settles on a terminal change request touched at or after the thread's latest event", () => {
55 for (const updatedAt of [THREAD_CREATED_AT, "2026-04-02T00:00:00.000Z"]) {
56 expect(changeRequestAutoSettles({ state: "merged", updatedAt }, { thread: idleThread })).toBe(
57 true,
58 );
59 }
60 });
61
62 it("never re-settles a thread revived after the merge", () => {
63 // Settling on a merge happens once: a user message newer than the PR's
64 // last activity means the conversation outlived the PR.
65 const revived = {
66 createdAt: THREAD_CREATED_AT,
67 latestUserMessageAt: "2026-04-05T00:00:00.000Z",
68 latestTurn: null,
69 };
70 expect(
71 changeRequestAutoSettles(
72 { state: "merged", updatedAt: "2026-04-03T00:00:00.000Z" },
73 { thread: revived },
74 ),
75 ).toBe(false);
76 // A merge landing after the revival still settles.
77 expect(
78 changeRequestAutoSettles(
79 { state: "merged", updatedAt: "2026-04-06T00:00:00.000Z" },
80 { thread: revived },
81 ),
82 ).toBe(true);
83 });
84
85 it("still settles when the merge lands during an in-flight turn", () => {
86 // Anchor is user-initiated activity only: the agent finishing a turn
87 // after the merge must not block the settle the merge earned.
88 const midTurnMerge = {
89 createdAt: THREAD_CREATED_AT,
90 latestUserMessageAt: "2026-04-02T00:00:00.000Z",
91 latestTurn: {
92 turnId: TurnId.make("turn-mid"),
93 state: "completed" as const,
94 requestedAt: "2026-04-02T00:00:00.000Z",
95 startedAt: "2026-04-02T00:00:05.000Z",
96 completedAt: "2026-04-02T00:20:00.000Z",
97 assistantMessageId: null,
98 },
99 };
100 expect(
101 changeRequestAutoSettles(
102 { state: "merged", updatedAt: "2026-04-02T00:10:00.000Z" },
103 { thread: midTurnMerge },
104 ),
105 ).toBe(true);
106 });
107
108 it("falls back to settling when either timestamp is missing or malformed", () => {
109 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true);
110 expect(
111 changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }),
112 ).toBe(true);
113 expect(
114 changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}),
115 ).toBe(true);
116 expect(
117 changeRequestAutoSettles(
118 { state: "merged", updatedAt: "not-a-date" },
119 { thread: idleThread },
120 ),
121 ).toBe(true);
32122 });
33123});
34124
......@@ -155,7 +245,7 @@ describe("effectiveSettled", () => {
155245 const changeRequestOptions =
156246 changeRequestState === undefined
157247 ? {}
158 : { changeRequestState: changeRequestState as ChangeRequestStateLike };
248 : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } };
159249
160250 expect(
161251 effectiveSettled(shell, {
......@@ -173,7 +263,7 @@ describe("effectiveSettled", () => {
173263 effectiveSettled(shell, {
174264 now: NOW,
175265 autoSettleAfterDays: null,
176 changeRequestState: "closed",
266 changeRequest: { state: "closed" },
177267 }),
178268 ).toBe(true);
179269 });
......@@ -185,12 +275,36 @@ describe("effectiveSettled", () => {
185275 effectiveSettled(recentlyActive, {
186276 now: NOW,
187277 autoSettleAfterDays: null,
188 changeRequestState,
278 changeRequest: { state: changeRequestState },
189279 }),
190280 ).toBe(true);
191281 }
192282 });
193283
284 it("ignores a change request that merged before the thread's latest event", () => {
285 // A new thread started at a worktree root inherits the branch's old
286 // merged PR, and a revived thread outlives its merge; neither settles
287 // the live conversation.
288 const fresh = makeShell({ activityAt: FRESH });
289 for (const state of ["merged", "closed"] as const) {
290 expect(
291 effectiveSettled(fresh, {
292 now: NOW,
293 autoSettleAfterDays: null,
294 changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" },
295 }),
296 ).toBe(false);
297 }
298 // A merge during the thread's life still settles it.
299 expect(
300 effectiveSettled(fresh, {
301 now: NOW,
302 autoSettleAfterDays: null,
303 changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" },
304 }),
305 ).toBe(true);
306 });
307
194308 it("can keep a merged change request active", () => {
195309 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
196310 expect(
......@@ -198,7 +312,7 @@ describe("effectiveSettled", () => {
198312 now: NOW,
199313 autoSettleAfterDays: null,
200314 autoSettleOnMerge: false,
201 changeRequestState: "merged",
315 changeRequest: { state: "merged" },
202316 }),
203317 ).toBe(false);
204318
......@@ -207,7 +321,7 @@ describe("effectiveSettled", () => {
207321 now: NOW,
208322 autoSettleAfterDays: null,
209323 autoSettleOnMerge: false,
210 changeRequestState: "closed",
324 changeRequest: { state: "closed" },
211325 }),
212326 ).toBe(true);
213327 });
......@@ -218,7 +332,7 @@ describe("effectiveSettled", () => {
218332 effectiveSettled(stale, {
219333 now: NOW,
220334 autoSettleAfterDays: 3,
221 changeRequestState: "open",
335 changeRequest: { state: "open" },
222336 }),
223337 ).toBe(false);
224338 // An explicit user settle still wins: open PR only blocks the auto path.
......@@ -227,7 +341,7 @@ describe("effectiveSettled", () => {
227341 effectiveSettled(settled, {
228342 now: NOW,
229343 autoSettleAfterDays: 3,
230 changeRequestState: "open",
344 changeRequest: { state: "open" },
231345 }),
232346 ).toBe(true);
233347 });
......@@ -241,7 +355,7 @@ describe("effectiveSettled", () => {
241355 effectiveSettled(shell, {
242356 now: NOW,
243357 autoSettleAfterDays: null,
244 changeRequestState: "merged",
358 changeRequest: { state: "merged" },
245359 }),
246360 ).toBe(false);
247361 });
......@@ -256,7 +370,7 @@ describe("effectiveSettled", () => {
256370 effectiveSettled(shell, {
257371 now: NOW,
258372 autoSettleAfterDays: 3,
259 changeRequestState: "merged",
373 changeRequest: { state: "merged" },
260374 }),
261375 ).toBe(false);
262376 });
......@@ -300,7 +414,7 @@ describe("effectiveSettled", () => {
300414 effectiveSettled(shell, {
301415 now: transitionNow,
302416 autoSettleAfterDays: 3,
303 changeRequestState: "merged",
417 changeRequest: { state: "merged" },
304418 }),
305419 ).toBe(false);
306420 }
......@@ -418,7 +532,7 @@ describe("canSettle", () => {
418532 effectiveSettled(queued, {
419533 now: justAfter,
420534 autoSettleAfterDays: 3,
421 changeRequestState: "merged",
535 changeRequest: { state: "merged" },
422536 }),
423537 ).toBe(false);
424538 // Past the window the message is a failed/stale start: settleable again.
packages/client-runtime/src/state/threadSettled.ts+77-10
......@@ -3,17 +3,77 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts";
33
44export type ChangeRequestStateLike = "open" | "closed" | "merged";
55
6/** Returns whether the change request state settles the thread immediately. */
6/**
7 * The slice of a change request the settle rules need. `updatedAt` is the
8 * provider's last-activity timestamp; for a merged/closed request it bounds
9 * when the terminal state landed.
10 */
11export interface ChangeRequestSettleSource {
12 readonly state: ChangeRequestStateLike;
13 readonly updatedAt?: string | null | undefined;
14}
15
16/** What the settle rules need to know about the thread's own timeline. */
17export type ThreadActivitySource = Pick<
18 OrchestrationThreadShell,
19 "createdAt" | "latestUserMessageAt" | "latestTurn"
20>;
21
22/**
23 * Latest USER-initiated activity: messages and the turn requests they start,
24 * deliberately not the agent-side started/completed stamps. The settle-on-
25 * merge anchor uses this so a merge landing mid-turn still settles the
26 * thread when that turn finishes, while a user re-engaging after the merge
27 * blocks it for good. Falls back to creation time for untouched threads.
28 */
29function threadUserActivityAnchorAt(thread: ThreadActivitySource): string {
30 const messageAt = thread.latestUserMessageAt;
31 const requestedAt = thread.latestTurn?.requestedAt;
32 let anchor = thread.createdAt;
33 for (const candidate of [messageAt, requestedAt]) {
34 if (candidate != null && Date.parse(candidate) > Date.parse(anchor)) {
35 anchor = candidate;
36 }
37 }
38 return anchor;
39}
40
41/**
42 * Returns whether the change request settles the thread immediately. A
43 * terminal request settles the thread only while it postdates every user-
44 * initiated event in it: settling on a merge happens ONCE. A request last
45 * touched before the thread was created is inherited branch history (a new
46 * thread started at a worktree root whose PR already merged), and one older
47 * than the user's latest engagement was already adjudicated — re-engaging a
48 * thread whose PR merged is the user saying the conversation outlived the
49 * PR. Unknown timestamps keep the old always-settle behavior.
50 */
751export function changeRequestAutoSettles(
8 state: ChangeRequestStateLike | null | undefined,
9 autoSettleOnMerge = true,
52 changeRequest: ChangeRequestSettleSource | null | undefined,
53 options: {
54 readonly autoSettleOnMerge?: boolean | undefined;
55 readonly thread?: ThreadActivitySource | null | undefined;
56 } = {},
1057): boolean {
11 return state === "closed" || (state === "merged" && autoSettleOnMerge);
58 if (changeRequest == null) return false;
59 const terminal =
60 changeRequest.state === "closed" ||
61 (changeRequest.state === "merged" && options.autoSettleOnMerge !== false);
62 if (!terminal) return false;
63 if (changeRequest.updatedAt == null || options.thread == null) return true;
64 const updatedAtMs = Date.parse(changeRequest.updatedAt);
65 const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread));
66 // Malformed timestamps fall back to settling, matching servers that never
67 // report updatedAt.
68 if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true;
69 return updatedAtMs >= anchorAtMs;
1270}
1371
1472const DAY_MS = 24 * 60 * 60 * 1_000;
1573
16export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null {
74export function threadLastActivityAt(
75 shell: Pick<OrchestrationThreadShell, "latestUserMessageAt" | "latestTurn">,
76): string | null {
1777 const candidates = [
1878 shell.latestUserMessageAt,
1979 shell.latestTurn?.requestedAt,
......@@ -230,8 +290,10 @@ export function threadWokeAt(
230290 * override. Past the blockers, the explicit user override (thread.settle /
231291 * thread.unsettle commands, projected into settledOverride + settledAt)
232292 * wins in both directions; without one, a thread can auto-settle on a
233 * merged PR, always settles on a closed PR, or settles on inactivity past
234 * the window. An open PR blocks the inactivity path entirely. The server
293 * merged PR or always on a closed PR (both only while the terminal state is
294 * the thread's latest event, see changeRequestAutoSettles), or settles on
295 * inactivity past the window.
296 * An open PR blocks the inactivity path entirely. The server
235297 * un-settles on real activity (user message, session start, approval/
236298 * user-input request), so an override never goes stale silently.
237299 */
......@@ -241,7 +303,7 @@ export function effectiveSettled(
241303 readonly now: string;
242304 readonly autoSettleAfterDays: number | null;
243305 readonly autoSettleOnMerge?: boolean;
244 readonly changeRequestState?: ChangeRequestStateLike | null;
306 readonly changeRequest?: ChangeRequestSettleSource | null;
245307 },
246308): boolean {
247309 // Blocked work must remain visible even when a user explicitly settled it.
......@@ -267,14 +329,19 @@ export function effectiveSettled(
267329 // "active" is the explicit keep-active pin: it suppresses auto-settle
268330 // until real activity clears it server-side.
269331 if (shell.settledOverride === "active") return false;
270 if (changeRequestAutoSettles(options.changeRequestState, options.autoSettleOnMerge !== false)) {
332 if (
333 changeRequestAutoSettles(options.changeRequest, {
334 autoSettleOnMerge: options.autoSettleOnMerge,
335 thread: shell,
336 })
337 ) {
271338 return true;
272339 }
273340 // An open PR is unfinished business regardless of how long the thread has
274341 // been quiet: review can take days, and hiding the thread would bury the
275342 // work waiting on it. A configured merge, a close, or an explicit user
276343 // settle resolves it.
277 if (options.changeRequestState === "open") return false;
344 if (options.changeRequest?.state === "open") return false;
278345 if (options.autoSettleAfterDays === null) return false;
279346
280347 const lastActivityAt = threadLastActivityAt(shell);
packages/contracts/src/git.ts+8
......@@ -197,6 +197,14 @@ const VcsStatusChangeRequest = Schema.Struct({
197197 baseRef: TrimmedNonEmptyStringSchema,
198198 headRef: TrimmedNonEmptyStringSchema,
199199 state: VcsStatusChangeRequestState,
200 /**
201 * Last provider-side activity (ISO). For a merged/closed change request
202 * this bounds when it reached that state, so clients can tell a PR that
203 * terminated during a thread's life from one that was already history
204 * when the thread was created. Optional for old servers and providers
205 * whose lookups do not report it.
206 */
207 updatedAt: Schema.optional(Schema.NullOr(Schema.String)),
200208});
201209
202210const VcsStatusLocalShape = {