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 {...@@ -12,6 +12,7 @@ import {
12 type EnvironmentThreadSearchMatch,12 type EnvironmentThreadSearchMatch,
13} from "@t3tools/client-runtime/state/thread-search";13} from "@t3tools/client-runtime/state/thread-search";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
15import type {16import type {
16 EnvironmentId,17 EnvironmentId,
17 SidebarProjectGroupingMode,18 SidebarProjectGroupingMode,
...@@ -488,18 +489,24 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -488,18 +489,24 @@ export function HomeScreen(props: HomeScreenProps) {
488 // optimistic holds.489 // optimistic holds.
489 // PR states stream in per-row. The next partition applies the configured490 // PR states stream in per-row. The next partition applies the configured
490 // merge rule and the always-on close rule, matching web.491 // merge rule and the always-on close rule, matching web.
491 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<492 const [changeRequestByKey, setChangeRequestByKey] = useState<
492 ReadonlyMap<string, "open" | "closed" | "merged">493 ReadonlyMap<string, ChangeRequestSettleSource>
493 >(() => new Map());494 >(() => new Map());
494 const handleChangeRequestState = useCallback(495 const handleChangeRequestState = useCallback(
495 (threadKey: string, state: "open" | "closed" | "merged" | null) => {496 (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
496 setChangeRequestStateByKey((current) => {497 setChangeRequestByKey((current) => {
497 if ((current.get(threadKey) ?? null) === state) return 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 }
498 const next = new Map(current);505 const next = new Map(current);
499 if (state === null) {506 if (changeRequest === null) {
500 next.delete(threadKey);507 next.delete(threadKey);
501 } else {508 } else {
502 next.set(threadKey, state);509 next.set(threadKey, changeRequest);
503 }510 }
504 return next;511 return next;
505 });512 });
...@@ -667,7 +674,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -667,7 +674,7 @@ export function HomeScreen(props: HomeScreenProps) {
667 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,674 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
668 searchQuery: props.searchQuery,675 searchQuery: props.searchQuery,
669 matchedThreadKeys,676 matchedThreadKeys,
670 changeRequestStateByKey,677 changeRequestByKey,
671 autoSettleOnMerge,678 autoSettleOnMerge,
672 settlementEnvironmentIds,679 settlementEnvironmentIds,
673 snoozeEnvironmentIds,680 snoozeEnvironmentIds,
...@@ -679,7 +686,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -679,7 +686,7 @@ export function HomeScreen(props: HomeScreenProps) {
679 selectedThreadKey: null,686 selectedThreadKey: null,
680 });687 });
681 }, [688 }, [
682 changeRequestStateByKey,689 changeRequestByKey,
683 autoSettleOnMerge,690 autoSettleOnMerge,
684 nowMinute,691 nowMinute,
685 snoozeWakeTick,692 snoozeWakeTick,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+16-9
...@@ -13,6 +13,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -13,6 +13,7 @@ import { useAtomValue } from "@effect/atom-react";
13import { AsyncResult } from "effect/unstable/reactivity";13import { AsyncResult } from "effect/unstable/reactivity";
14import type { EnvironmentId } from "@t3tools/contracts";14import type { EnvironmentId } from "@t3tools/contracts";
15import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";15import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
16import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
16import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";17import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
17import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";18import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
18import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";19import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
...@@ -420,18 +421,24 @@ function ThreadNavigationSidebarPane(...@@ -420,18 +421,24 @@ function ThreadNavigationSidebarPane(
420 // (HomeScreen.tsx): flat creation-order card block + settled recency tail.421 // (HomeScreen.tsx): flat creation-order card block + settled recency tail.
421 // PR states stream in per-row. The next partition applies the configured422 // PR states stream in per-row. The next partition applies the configured
422 // merge rule and the always-on close rule.423 // merge rule and the always-on close rule.
423 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<424 const [changeRequestByKey, setChangeRequestByKey] = useState<
424 ReadonlyMap<string, "open" | "closed" | "merged">425 ReadonlyMap<string, ChangeRequestSettleSource>
425 >(() => new Map());426 >(() => new Map());
426 const handleChangeRequestState = useCallback(427 const handleChangeRequestState = useCallback(
427 (threadKey: string, state: "open" | "closed" | "merged" | null) => {428 (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
428 setChangeRequestStateByKey((current) => {429 setChangeRequestByKey((current) => {
429 if ((current.get(threadKey) ?? null) === state) return 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 }
430 const next = new Map(current);437 const next = new Map(current);
431 if (state === null) {438 if (changeRequest === null) {
432 next.delete(threadKey);439 next.delete(threadKey);
433 } else {440 } else {
434 next.set(threadKey, state);441 next.set(threadKey, changeRequest);
435 }442 }
436 return next;443 return next;
437 });444 });
...@@ -552,7 +559,7 @@ function ThreadNavigationSidebarPane(...@@ -552,7 +559,7 @@ function ThreadNavigationSidebarPane(
552 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,559 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
553 searchQuery: props.searchQuery,560 searchQuery: props.searchQuery,
554 matchedThreadKeys,561 matchedThreadKeys,
555 changeRequestStateByKey,562 changeRequestByKey,
556 autoSettleOnMerge,563 autoSettleOnMerge,
557 settlementEnvironmentIds,564 settlementEnvironmentIds,
558 snoozeEnvironmentIds,565 snoozeEnvironmentIds,
...@@ -564,7 +571,7 @@ function ThreadNavigationSidebarPane(...@@ -564,7 +571,7 @@ function ThreadNavigationSidebarPane(
564 selectedThreadKey: props.selectedThreadKey ?? null,571 selectedThreadKey: props.selectedThreadKey ?? null,
565 });572 });
566 }, [573 }, [
567 changeRequestStateByKey,574 changeRequestByKey,
568 autoSettleOnMerge,575 autoSettleOnMerge,
569 nowMinute,576 nowMinute,
570 snoozeWakeTick,577 snoozeWakeTick,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+14-6
...@@ -3,7 +3,11 @@ import type {...@@ -3,7 +3,11 @@ import type {
3 EnvironmentThreadShell,3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";4} from "@t3tools/client-runtime/state/shell";
5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";5import 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";
7import type { MenuAction } from "@react-native-menu/menu";11import type { MenuAction } from "@react-native-menu/menu";
8import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";12import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
9import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";13import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
...@@ -365,11 +369,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -365,11 +369,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
365 readonly canMovePinnedDown?: boolean;369 readonly canMovePinnedDown?: boolean;
366 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;370 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
367 readonly onSwipeableClose: (methods: SwipeableMethods) => void;371 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
368 /** Reports this row's live PR state for the partition's merge and close372 /** Reports this row's live PR (state + last activity) for the partition's
369 rules. Mirrors web's onChangeRequestState. */373 merge and close rules. Mirrors web's onChangeRequestState. */
370 readonly onChangeRequestState?: (374 readonly onChangeRequestState?: (
371 threadKey: string,375 threadKey: string,
372 state: "open" | "closed" | "merged" | null,376 changeRequest: ChangeRequestSettleSource | null,
373 ) => void;377 ) => void;
374 readonly projectCwd?: string | null;378 readonly projectCwd?: string | null;
375 readonly searchMatch?: EnvironmentThreadSearchMatch;379 readonly searchMatch?: EnvironmentThreadSearchMatch;
...@@ -400,10 +404,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -400,10 +404,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
400404
401 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);405 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
402 const prState = pr?.state ?? null;406 const prState = pr?.state ?? null;
407 const prUpdatedAt = pr?.updatedAt ?? null;
403 const threadKey = `${thread.environmentId}:${thread.id}`;408 const threadKey = `${thread.environmentId}:${thread.id}`;
404 useEffect(() => {409 useEffect(() => {
405 onChangeRequestState?.(threadKey, prState);410 onChangeRequestState?.(
406 }, [onChangeRequestState, prState, threadKey]);411 threadKey,
412 prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
413 );
414 }, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
407415
408 const screenColor = useThemeColor("--color-screen");416 const screenColor = useThemeColor("--color-screen");
409 const drawerColor = useThemeColor("--color-drawer");417 const drawerColor = useThemeColor("--color-drawer");
apps/mobile/src/features/threads/threadListV2.test.ts+3-1
...@@ -269,7 +269,9 @@ describe("buildThreadListV2Items", () => {...@@ -269,7 +269,9 @@ describe("buildThreadListV2Items", () => {
269 threads: [merged],269 threads: [merged],
270 environmentId: null,270 environmentId: null,
271 searchQuery: "",271 searchQuery: "",
272 changeRequestStateByKey: new Map([[`${environmentId}:${merged.id}`, "merged"]]),272 changeRequestByKey: new Map([
273 [`${environmentId}:${merged.id}`, { state: "merged" as const }],
274 ]),
273 autoSettleOnMerge: false,275 autoSettleOnMerge: false,
274 now: NOW,276 now: NOW,
275 });277 });
apps/mobile/src/features/threads/threadListV2.ts+9-6
...@@ -6,7 +6,10 @@ import {...@@ -6,7 +6,10 @@ import {
6 resolveSnoozePresets,6 resolveSnoozePresets,
7 snoozeWakeLabel,7 snoozeWakeLabel,
8} from "@t3tools/client-runtime/state/thread-settled";8} 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";
10import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";13import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
11import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";14import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
12import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";15import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
...@@ -318,8 +321,8 @@ export function buildThreadListV2Items(input: {...@@ -318,8 +321,8 @@ export function buildThreadListV2Items(input: {
318 }> | null;321 }> | null;
319 readonly searchQuery: string;322 readonly searchQuery: string;
320 readonly matchedThreadKeys?: ReadonlySet<string>;323 readonly matchedThreadKeys?: ReadonlySet<string>;
321 /** Per-row PR state reported up by visible rows ("env:threadId" keys). */324 /** Per-row PR reported up by visible rows ("env:threadId" keys). */
322 readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">;325 readonly changeRequestByKey?: ReadonlyMap<string, ChangeRequestSettleSource>;
323 /** Environments whose server supports thread.settle/unsettle. Threads on326 /** Environments whose server supports thread.settle/unsettle. Threads on
324 other environments never classify as settled — the user could neither327 other environments never classify as settled — the user could neither
325 un-settle nor pin them. Absent = no gating (tests). */328 un-settle nor pin them. Absent = no gating (tests). */
...@@ -381,8 +384,8 @@ export function buildThreadListV2Items(input: {...@@ -381,8 +384,8 @@ export function buildThreadListV2Items(input: {
381 }384 }
382 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;385 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
383 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;386 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
384 const changeRequestState =387 const changeRequest =
385 input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;388 input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
386 // Visibility parity with web: snooze outranks everything, including a389 // Visibility parity with web: snooze outranks everything, including a
387 // pin — a snoozed thread leaves the list until it wakes (or raises its390 // pin — a snoozed thread leaves the list until it wakes (or raises its
388 // hand). The pin (and its pinOrderKey) survives underneath, so a woken391 // hand). The pin (and its pinOrderKey) survives underneath, so a woken
...@@ -410,7 +413,7 @@ export function buildThreadListV2Items(input: {...@@ -410,7 +413,7 @@ export function buildThreadListV2Items(input: {
410 now,413 now,
411 autoSettleAfterDays,414 autoSettleAfterDays,
412 autoSettleOnMerge,415 autoSettleOnMerge,
413 changeRequestState,416 changeRequest,
414 })417 })
415 ) {418 ) {
416 settled.push(thread);419 settled.push(thread);
apps/mobile/src/state/thread-pr-presentation.ts+3
...@@ -6,6 +6,8 @@ export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;...@@ -6,6 +6,8 @@ export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;
6export interface ThreadPrPresentation {6export interface ThreadPrPresentation {
7 readonly number: number;7 readonly number: number;
8 readonly state: ThreadPr["state"];8 readonly state: ThreadPr["state"];
9 /** Provider-side last activity, bounding when a terminal state landed. */
10 readonly updatedAt: string | null;
9 readonly url: string;11 readonly url: string;
10 /** Compact pull request number label, e.g. "3774". */12 /** Compact pull request number label, e.g. "3774". */
11 readonly label: string;13 readonly label: string;
...@@ -28,6 +30,7 @@ export function presentThreadPr(...@@ -28,6 +30,7 @@ export function presentThreadPr(
28 return {30 return {
29 number: pr.number,31 number: pr.number,
30 state: pr.state,32 state: pr.state,
33 updatedAt: pr.updatedAt ?? null,
31 url: pr.url,34 url: pr.url,
32 label: String(pr.number),35 label: String(pr.number),
33 accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`,36 accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`,
apps/server/src/git/GitManager.test.ts+9
...@@ -717,6 +717,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -717,6 +717,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
717 baseRef: "main",717 baseRef: "main",
718 headRef: "feature/status-open-pr",718 headRef: "feature/status-open-pr",
719 state: "open",719 state: "open",
720 updatedAt: null,
720 });721 });
721 }),722 }),
722 );723 );
...@@ -756,6 +757,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -756,6 +757,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
756 baseRef: "main",757 baseRef: "main",
757 headRef: "feature/status-trimmed-pr",758 headRef: "feature/status-trimmed-pr",
758 state: "open",759 state: "open",
760 updatedAt: null,
759 });761 });
760 }),762 }),
761 );763 );
...@@ -808,6 +810,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -808,6 +810,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
808 baseRef: "main",810 baseRef: "main",
809 headRef: "feature/status-valid-pr-entry",811 headRef: "feature/status-valid-pr-entry",
810 state: "open",812 state: "open",
813 updatedAt: null,
811 });814 });
812 }),815 }),
813 );816 );
...@@ -858,6 +861,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -858,6 +861,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
858 baseRef: "main",861 baseRef: "main",
859 headRef: "feature/status-lowercase-state",862 headRef: "feature/status-lowercase-state",
860 state: "merged",863 state: "merged",
864 updatedAt: "2026-01-02T00:00:00.000Z",
861 });865 });
862 }),866 }),
863 );867 );
...@@ -1121,6 +1125,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1121,6 +1125,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1121 baseRef: "main",1125 baseRef: "main",
1122 headRef: "statemachine",1126 headRef: "statemachine",
1123 state: "open",1127 state: "open",
1128 updatedAt: "2026-03-10T07:00:00.000Z",
1124 });1129 });
1125 expect(ghCalls).toContain(1130 expect(ghCalls).toContain(
1126 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",1131 "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) => {...@@ -1186,6 +1191,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1186 baseRef: "main",1191 baseRef: "main",
1187 headRef: "main",1192 headRef: "main",
1188 state: "open",1193 state: "open",
1194 updatedAt: "2026-03-10T07:00:00.000Z",
1189 });1195 });
1190 expect(ghCalls).toContain(1196 expect(ghCalls).toContain(
1191 "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",1197 "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) => {...@@ -1294,6 +1300,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1294 baseRef: "main",1300 baseRef: "main",
1295 headRef: "effect-atom",1301 headRef: "effect-atom",
1296 state: "open",1302 state: "open",
1303 updatedAt: "2026-03-01T10:00:00.000Z",
1297 });1304 });
1298 expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(1305 expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(
1299 false,1306 false,
...@@ -1345,6 +1352,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1345,6 +1352,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1345 baseRef: "main",1352 baseRef: "main",
1346 headRef: "feature/status-merged-pr",1353 headRef: "feature/status-merged-pr",
1347 state: "merged",1354 state: "merged",
1355 updatedAt: "2026-01-30T10:00:00.000Z",
1348 });1356 });
1349 }),1357 }),
1350 );1358 );
...@@ -1461,6 +1469,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1461,6 +1469,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1461 baseRef: "main",1469 baseRef: "main",
1462 headRef: "feature/status-open-over-merged",1470 headRef: "feature/status-open-over-merged",
1463 state: "open",1471 state: "open",
1472 updatedAt: "2026-01-30T10:00:00.000Z",
1464 });1473 });
1465 }),1474 }),
1466 );1475 );
apps/server/src/git/GitManager.ts+5
...@@ -538,6 +538,7 @@ function toStatusPr(pr: PullRequestInfo): {...@@ -538,6 +538,7 @@ function toStatusPr(pr: PullRequestInfo): {
538 baseRef: string;538 baseRef: string;
539 headRef: string;539 headRef: string;
540 state: "open" | "closed" | "merged";540 state: "open" | "closed" | "merged";
541 updatedAt: string | null;
541} {542} {
542 return {543 return {
543 number: pr.number,544 number: pr.number,
...@@ -546,6 +547,10 @@ function toStatusPr(pr: PullRequestInfo): {...@@ -546,6 +547,10 @@ function toStatusPr(pr: PullRequestInfo): {
546 baseRef: pr.baseRefName,547 baseRef: pr.baseRefName,
547 headRef: pr.headRefName,548 headRef: pr.headRefName,
548 state: pr.state,549 state: pr.state,
550 updatedAt: Option.match(pr.updatedAt, {
551 onNone: () => null,
552 onSome: (updatedAt) => DateTime.formatIso(updatedAt),
553 }),
549 };554 };
550}555}
551556
apps/web/src/components/ChatView.tsx+25-5
...@@ -4151,6 +4151,18 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4151,6 +4151,18 @@ function ChatViewContent(props: ChatViewProps) {
4151 }, [activeThreadPr, openThreadPullRequest]);4151 }, [activeThreadPr, openThreadPullRequest]);
4152 const pullRequestSurfaceAvailable =4152 const pullRequestSurfaceAvailable =
4153 supportsPullRequests && activeThreadPr !== null && threadRepository !== null;4153 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 );
4154 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;4166 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
4155 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;4167 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
4156 const nowMinute = useNowMinute();4168 const nowMinute = useNowMinute();
...@@ -4186,7 +4198,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4186,7 +4198,14 @@ function ChatViewContent(props: ChatViewProps) {
4186 );4198 );
4187 const activeThreadWokeVisible = useMemo(() => {4199 const activeThreadWokeVisible = useMemo(() => {
4188 if (activeThreadWokeAt === null) return false;4200 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 }
4190 const wokeAtMs = Date.parse(activeThreadWokeAt);4209 const wokeAtMs = Date.parse(activeThreadWokeAt);
4191 if (Number.isNaN(wokeAtMs)) return false;4210 if (Number.isNaN(wokeAtMs)) return false;
4192 // Having the thread open counts as a visit at completedAt (the effect4211 // Having the thread open counts as a visit at completedAt (the effect
...@@ -4206,7 +4225,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4206,7 +4225,8 @@ function ChatViewContent(props: ChatViewProps) {
4206 }, [4225 }, [
4207 activeLatestTurn?.completedAt,4226 activeLatestTurn?.completedAt,
4208 activeThreadLastVisitedAt,4227 activeThreadLastVisitedAt,
4209 activeThreadPr?.state,4228 activeThreadChangeRequest,
4229 activeThreadShell,
4210 activeThreadWokeAt,4230 activeThreadWokeAt,
4211 autoSettleOnMerge,4231 autoSettleOnMerge,
4212 ]);4232 ]);
...@@ -4216,10 +4236,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4216,10 +4236,10 @@ function ChatViewContent(props: ChatViewProps) {
4216 now: `${nowMinute}:00.000Z`,4236 now: `${nowMinute}:00.000Z`,
4217 autoSettleAfterDays,4237 autoSettleAfterDays,
4218 autoSettleOnMerge,4238 autoSettleOnMerge,
4219 changeRequestState: activeThreadPr?.state ?? null,4239 changeRequest: activeThreadChangeRequest,
4220 });4240 });
4221 }, [4241 }, [
4222 activeThreadPr?.state,4242 activeThreadChangeRequest,
4223 activeThreadShell,4243 activeThreadShell,
4224 autoSettleAfterDays,4244 autoSettleAfterDays,
4225 autoSettleOnMerge,4245 autoSettleOnMerge,
...@@ -6245,7 +6265,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6245,7 +6265,7 @@ function ChatViewContent(props: ChatViewProps) {
6245 {...(routeKind === "draft" && draftId ? { draftId } : {})}6265 {...(routeKind === "draft" && draftId ? { draftId } : {})}
6246 activeThreadTitle={activeThread.title}6266 activeThreadTitle={activeThread.title}
6247 isServerThread={isServerThread}6267 isServerThread={isServerThread}
6248 changeRequestState={activeThreadPr?.state ?? null}6268 changeRequest={activeThreadChangeRequest}
6249 activeProjectName={activeProject?.title}6269 activeProjectName={activeProject?.title}
6250 activeProjectCwd={activeProject?.workspaceRoot ?? null}6270 activeProjectCwd={activeProject?.workspaceRoot ?? null}
6251 activeProjectFaviconPath={activeProject?.faviconPath ?? null}6271 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
apps/web/src/components/Sidebar.tsx+7-5
...@@ -801,7 +801,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -801,7 +801,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
801 snapshot: changeRequestSnapshot,801 snapshot: changeRequestSnapshot,
802 retainTerminalOnBranchMismatch,802 retainTerminalOnBranchMismatch,
803 });803 });
804 const prState = pr?.state ?? null;
805804
806 // Same semantics as the legacy sidebar (never-visited counts as read):805 // Same semantics as the legacy sidebar (never-visited counts as read):
807 // switching sidebars must not light up every historical thread as unread.806 // switching sidebars must not light up every historical thread as unread.
...@@ -819,7 +818,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -819,7 +818,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
819 const isWoke =818 const isWoke =
820 wokeAtDate !== null &&819 wokeAtDate !== null &&
821 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&820 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
822 !changeRequestAutoSettles(prState, props.autoSettleOnMerge);821 !changeRequestAutoSettles(pr, {
822 autoSettleOnMerge: props.autoSettleOnMerge,
823 thread,
824 });
823 // In-flight rows (working, or waiting on approval/input) fade as a whole:825 // In-flight rows (working, or waiting on approval/input) fade as a whole:
824 // there is nothing for the user to do yet, so prominence is reserved for826 // there is nothing for the user to do yet, so prominence is reserved for
825 // rows that need a human — done (unread), read-but-unsettled, failed, and827 // rows that need a human — done (unread), read-but-unsettled, failed, and
...@@ -2024,9 +2026,9 @@ export default function Sidebar() {...@@ -2024,9 +2026,9 @@ export default function Sidebar() {
2024 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;2026 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
2025 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));2027 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
2026 const snapshot = changeRequestSnapshotByKey.get(threadKey);2028 const snapshot = changeRequestSnapshotByKey.get(threadKey);
2027 const changeRequestState =2029 const changeRequest =
2028 snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch)2030 snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch)
2029 ? snapshot.pr.state2031 ? snapshot.pr
2030 : null;2032 : null;
2031 // Snooze outranks everything, including a pin: "hide until Tuesday"2033 // Snooze outranks everything, including a pin: "hide until Tuesday"
2032 // temporarily suspends "keep on top". The pin survives underneath —2034 // temporarily suspends "keep on top". The pin survives underneath —
...@@ -2048,7 +2050,7 @@ export default function Sidebar() {...@@ -2048,7 +2050,7 @@ export default function Sidebar() {
2048 now,2050 now,
2049 autoSettleAfterDays,2051 autoSettleAfterDays,
2050 autoSettleOnMerge,2052 autoSettleOnMerge,
2051 changeRequestState,2053 changeRequest,
2052 })2054 })
2053 ) {2055 ) {
2054 settled.push(thread);2056 settled.push(thread);
apps/web/src/components/ThreadStatusIndicators.test.ts+1-1
...@@ -426,7 +426,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {...@@ -426,7 +426,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
426 effectiveSettled(shell, {426 effectiveSettled(shell, {
427 now: "2026-04-10T00:00:00.000Z",427 now: "2026-04-10T00:00:00.000Z",
428 autoSettleAfterDays: null,428 autoSettleAfterDays: null,
429 changeRequestState: displayed?.state ?? null,429 changeRequest: displayed,
430 }),430 }),
431 ).toBe(true);431 ).toBe(true);
432 });432 });
apps/web/src/components/ThreadStatusIndicators.tsx+1
...@@ -169,6 +169,7 @@ export function threadChangeRequestSnapshotsEqual(...@@ -169,6 +169,7 @@ export function threadChangeRequestSnapshotsEqual(
169 left.pr.baseRef === right.pr.baseRef &&169 left.pr.baseRef === right.pr.baseRef &&
170 left.pr.headRef === right.pr.headRef &&170 left.pr.headRef === right.pr.headRef &&
171 left.pr.state === right.pr.state &&171 left.pr.state === right.pr.state &&
172 (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) &&
172 sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider)173 sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider)
173 );174 );
174}175}
apps/web/src/components/chat/ChatHeader.tsx+5-5
...@@ -10,7 +10,7 @@ import {...@@ -10,7 +10,7 @@ import {
10 isAtomCommandInterrupted,10 isAtomCommandInterrupted,
11 squashAtomCommandFailure,11 squashAtomCommandFailure,
12} from "@t3tools/client-runtime/state/runtime";12} 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";
14import { ChevronDownIcon } from "lucide-react";14import { ChevronDownIcon } from "lucide-react";
15import {15import {
16 memo,16 memo,
...@@ -51,8 +51,8 @@ interface ChatHeaderProps {...@@ -51,8 +51,8 @@ interface ChatHeaderProps {
51 activeThreadTitle: string;51 activeThreadTitle: string;
52 /** Drafts have no server thread yet, so the title carries no action menu. */52 /** Drafts have no server thread yet, so the title carries no action menu. */
53 isServerThread: boolean;53 isServerThread: boolean;
54 /** PR state feeding the settled classification, resolved by ChatView. */54 /** PR feeding the settled classification, resolved by ChatView. */
55 changeRequestState: ChangeRequestStateLike | null;55 changeRequest: ChangeRequestSettleSource | null;
56 activeProjectName: string | undefined;56 activeProjectName: string | undefined;
57 activeProjectCwd: string | null;57 activeProjectCwd: string | null;
58 activeProjectFaviconPath: string | null;58 activeProjectFaviconPath: string | null;
...@@ -113,7 +113,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -113,7 +113,7 @@ export const ChatHeader = memo(function ChatHeader({
113 draftId,113 draftId,
114 activeThreadTitle,114 activeThreadTitle,
115 isServerThread,115 isServerThread,
116 changeRequestState,116 changeRequest,
117 activeProjectName,117 activeProjectName,
118 activeProjectCwd,118 activeProjectCwd,
119 activeProjectFaviconPath,119 activeProjectFaviconPath,
...@@ -191,7 +191,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -191,7 +191,7 @@ export const ChatHeader = memo(function ChatHeader({
191 const { openMenu } = useThreadActionMenu({191 const { openMenu } = useThreadActionMenu({
192 threadRef: isServerThread ? activeThreadRef : null,192 threadRef: isServerThread ? activeThreadRef : null,
193 projectCwd: activeProjectCwd,193 projectCwd: activeProjectCwd,
194 changeRequestState,194 changeRequest,
195 onStartRename: startRename,195 onStartRename: startRename,
196 });196 });
197 const titleButtonRef = useRef<HTMLButtonElement | null>(null);197 const titleButtonRef = useRef<HTMLButtonElement | null>(null);
apps/web/src/hooks/useThreadActionMenu.ts+6-6
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9 canSnooze,9 canSnooze,
10 effectiveSettled,10 effectiveSettled,
11 effectiveSnoozed,11 effectiveSnoozed,
12 type ChangeRequestStateLike,12 type ChangeRequestSettleSource,
13} from "@t3tools/client-runtime/state/thread-settled";13} from "@t3tools/client-runtime/state/thread-settled";
14import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";14import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
15import { useCallback } from "react";15import { useCallback } from "react";
...@@ -60,11 +60,11 @@ export function useThreadActionMenu(input: {...@@ -60,11 +60,11 @@ export function useThreadActionMenu(input: {
60 readonly threadRef: ScopedThreadRef | null;60 readonly threadRef: ScopedThreadRef | null;
61 /** Fallback for "Copy path" when the thread has no worktree. */61 /** Fallback for "Copy path" when the thread has no worktree. */
62 readonly projectCwd: string | null;62 readonly projectCwd: string | null;
63 /** PR state feeding auto-settle classification, as resolved by the caller. */63 /** PR feeding auto-settle classification, as resolved by the caller. */
64 readonly changeRequestState: ChangeRequestStateLike | null;64 readonly changeRequest: ChangeRequestSettleSource | null;
65 readonly onStartRename: () => void;65 readonly onStartRename: () => void;
66}) {66}) {
67 const { threadRef, projectCwd, changeRequestState, onStartRename } = input;67 const { threadRef, projectCwd, changeRequest, onStartRename } = input;
68 const {68 const {
69 settleThread,69 settleThread,
70 unsettleThread,70 unsettleThread,
...@@ -136,7 +136,7 @@ export function useThreadActionMenu(input: {...@@ -136,7 +136,7 @@ export function useThreadActionMenu(input: {
136 now: `${now.toISOString().slice(0, 16)}:00.000Z`,136 now: `${now.toISOString().slice(0, 16)}:00.000Z`,
137 autoSettleAfterDays,137 autoSettleAfterDays,
138 autoSettleOnMerge,138 autoSettleOnMerge,
139 changeRequestState,139 changeRequest,
140 }),140 }),
141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
142 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),142 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
...@@ -312,7 +312,7 @@ export function useThreadActionMenu(input: {...@@ -312,7 +312,7 @@ export function useThreadActionMenu(input: {
312 archiveThread,312 archiveThread,
313 autoSettleAfterDays,313 autoSettleAfterDays,
314 autoSettleOnMerge,314 autoSettleOnMerge,
315 changeRequestState,315 changeRequest,
316 confirmThreadArchive,316 confirmThreadArchive,
317 confirmThreadDelete,317 confirmThreadDelete,
318 copyBranchToClipboard,318 copyBranchToClipboard,
packages/client-runtime/src/state/threadSettled.test.ts+126-12
...@@ -28,7 +28,97 @@ describe("changeRequestAutoSettles", () => {...@@ -28,7 +28,97 @@ describe("changeRequestAutoSettles", () => {
28 ["closed", false, true],28 ["closed", false, true],
29 [null, false, false],29 [null, false, false],
30 ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => {30 ] 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);
32 });122 });
33});123});
34124
...@@ -155,7 +245,7 @@ describe("effectiveSettled", () => {...@@ -155,7 +245,7 @@ describe("effectiveSettled", () => {
155 const changeRequestOptions =245 const changeRequestOptions =
156 changeRequestState === undefined246 changeRequestState === undefined
157 ? {}247 ? {}
158 : { changeRequestState: changeRequestState as ChangeRequestStateLike };248 : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } };
159249
160 expect(250 expect(
161 effectiveSettled(shell, {251 effectiveSettled(shell, {
...@@ -173,7 +263,7 @@ describe("effectiveSettled", () => {...@@ -173,7 +263,7 @@ describe("effectiveSettled", () => {
173 effectiveSettled(shell, {263 effectiveSettled(shell, {
174 now: NOW,264 now: NOW,
175 autoSettleAfterDays: null,265 autoSettleAfterDays: null,
176 changeRequestState: "closed",266 changeRequest: { state: "closed" },
177 }),267 }),
178 ).toBe(true);268 ).toBe(true);
179 });269 });
...@@ -185,12 +275,36 @@ describe("effectiveSettled", () => {...@@ -185,12 +275,36 @@ describe("effectiveSettled", () => {
185 effectiveSettled(recentlyActive, {275 effectiveSettled(recentlyActive, {
186 now: NOW,276 now: NOW,
187 autoSettleAfterDays: null,277 autoSettleAfterDays: null,
188 changeRequestState,278 changeRequest: { state: changeRequestState },
189 }),279 }),
190 ).toBe(true);280 ).toBe(true);
191 }281 }
192 });282 });
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
194 it("can keep a merged change request active", () => {308 it("can keep a merged change request active", () => {
195 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });309 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
196 expect(310 expect(
...@@ -198,7 +312,7 @@ describe("effectiveSettled", () => {...@@ -198,7 +312,7 @@ describe("effectiveSettled", () => {
198 now: NOW,312 now: NOW,
199 autoSettleAfterDays: null,313 autoSettleAfterDays: null,
200 autoSettleOnMerge: false,314 autoSettleOnMerge: false,
201 changeRequestState: "merged",315 changeRequest: { state: "merged" },
202 }),316 }),
203 ).toBe(false);317 ).toBe(false);
204318
...@@ -207,7 +321,7 @@ describe("effectiveSettled", () => {...@@ -207,7 +321,7 @@ describe("effectiveSettled", () => {
207 now: NOW,321 now: NOW,
208 autoSettleAfterDays: null,322 autoSettleAfterDays: null,
209 autoSettleOnMerge: false,323 autoSettleOnMerge: false,
210 changeRequestState: "closed",324 changeRequest: { state: "closed" },
211 }),325 }),
212 ).toBe(true);326 ).toBe(true);
213 });327 });
...@@ -218,7 +332,7 @@ describe("effectiveSettled", () => {...@@ -218,7 +332,7 @@ describe("effectiveSettled", () => {
218 effectiveSettled(stale, {332 effectiveSettled(stale, {
219 now: NOW,333 now: NOW,
220 autoSettleAfterDays: 3,334 autoSettleAfterDays: 3,
221 changeRequestState: "open",335 changeRequest: { state: "open" },
222 }),336 }),
223 ).toBe(false);337 ).toBe(false);
224 // An explicit user settle still wins: open PR only blocks the auto path.338 // An explicit user settle still wins: open PR only blocks the auto path.
...@@ -227,7 +341,7 @@ describe("effectiveSettled", () => {...@@ -227,7 +341,7 @@ describe("effectiveSettled", () => {
227 effectiveSettled(settled, {341 effectiveSettled(settled, {
228 now: NOW,342 now: NOW,
229 autoSettleAfterDays: 3,343 autoSettleAfterDays: 3,
230 changeRequestState: "open",344 changeRequest: { state: "open" },
231 }),345 }),
232 ).toBe(true);346 ).toBe(true);
233 });347 });
...@@ -241,7 +355,7 @@ describe("effectiveSettled", () => {...@@ -241,7 +355,7 @@ describe("effectiveSettled", () => {
241 effectiveSettled(shell, {355 effectiveSettled(shell, {
242 now: NOW,356 now: NOW,
243 autoSettleAfterDays: null,357 autoSettleAfterDays: null,
244 changeRequestState: "merged",358 changeRequest: { state: "merged" },
245 }),359 }),
246 ).toBe(false);360 ).toBe(false);
247 });361 });
...@@ -256,7 +370,7 @@ describe("effectiveSettled", () => {...@@ -256,7 +370,7 @@ describe("effectiveSettled", () => {
256 effectiveSettled(shell, {370 effectiveSettled(shell, {
257 now: NOW,371 now: NOW,
258 autoSettleAfterDays: 3,372 autoSettleAfterDays: 3,
259 changeRequestState: "merged",373 changeRequest: { state: "merged" },
260 }),374 }),
261 ).toBe(false);375 ).toBe(false);
262 });376 });
...@@ -300,7 +414,7 @@ describe("effectiveSettled", () => {...@@ -300,7 +414,7 @@ describe("effectiveSettled", () => {
300 effectiveSettled(shell, {414 effectiveSettled(shell, {
301 now: transitionNow,415 now: transitionNow,
302 autoSettleAfterDays: 3,416 autoSettleAfterDays: 3,
303 changeRequestState: "merged",417 changeRequest: { state: "merged" },
304 }),418 }),
305 ).toBe(false);419 ).toBe(false);
306 }420 }
...@@ -418,7 +532,7 @@ describe("canSettle", () => {...@@ -418,7 +532,7 @@ describe("canSettle", () => {
418 effectiveSettled(queued, {532 effectiveSettled(queued, {
419 now: justAfter,533 now: justAfter,
420 autoSettleAfterDays: 3,534 autoSettleAfterDays: 3,
421 changeRequestState: "merged",535 changeRequest: { state: "merged" },
422 }),536 }),
423 ).toBe(false);537 ).toBe(false);
424 // Past the window the message is a failed/stale start: settleable again.538 // 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";...@@ -3,17 +3,77 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts";
33
4export type ChangeRequestStateLike = "open" | "closed" | "merged";4export 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 */
7export function changeRequestAutoSettles(51export function changeRequestAutoSettles(
8 state: ChangeRequestStateLike | null | undefined,52 changeRequest: ChangeRequestSettleSource | null | undefined,
9 autoSettleOnMerge = true,53 options: {
54 readonly autoSettleOnMerge?: boolean | undefined;
55 readonly thread?: ThreadActivitySource | null | undefined;
56 } = {},
10): boolean {57): 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;
12}70}
1371
14const DAY_MS = 24 * 60 * 60 * 1_000;72const 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 {
17 const candidates = [77 const candidates = [
18 shell.latestUserMessageAt,78 shell.latestUserMessageAt,
19 shell.latestTurn?.requestedAt,79 shell.latestTurn?.requestedAt,
...@@ -230,8 +290,10 @@ export function threadWokeAt(...@@ -230,8 +290,10 @@ export function threadWokeAt(
230 * override. Past the blockers, the explicit user override (thread.settle /290 * override. Past the blockers, the explicit user override (thread.settle /
231 * thread.unsettle commands, projected into settledOverride + settledAt)291 * thread.unsettle commands, projected into settledOverride + settledAt)
232 * wins in both directions; without one, a thread can auto-settle on a292 * 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 past293 * merged PR or always on a closed PR (both only while the terminal state is
234 * the window. An open PR blocks the inactivity path entirely. The server294 * 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
235 * un-settles on real activity (user message, session start, approval/297 * un-settles on real activity (user message, session start, approval/
236 * user-input request), so an override never goes stale silently.298 * user-input request), so an override never goes stale silently.
237 */299 */
...@@ -241,7 +303,7 @@ export function effectiveSettled(...@@ -241,7 +303,7 @@ export function effectiveSettled(
241 readonly now: string;303 readonly now: string;
242 readonly autoSettleAfterDays: number | null;304 readonly autoSettleAfterDays: number | null;
243 readonly autoSettleOnMerge?: boolean;305 readonly autoSettleOnMerge?: boolean;
244 readonly changeRequestState?: ChangeRequestStateLike | null;306 readonly changeRequest?: ChangeRequestSettleSource | null;
245 },307 },
246): boolean {308): boolean {
247 // Blocked work must remain visible even when a user explicitly settled it.309 // Blocked work must remain visible even when a user explicitly settled it.
...@@ -267,14 +329,19 @@ export function effectiveSettled(...@@ -267,14 +329,19 @@ export function effectiveSettled(
267 // "active" is the explicit keep-active pin: it suppresses auto-settle329 // "active" is the explicit keep-active pin: it suppresses auto-settle
268 // until real activity clears it server-side.330 // until real activity clears it server-side.
269 if (shell.settledOverride === "active") return false;331 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 ) {
271 return true;338 return true;
272 }339 }
273 // An open PR is unfinished business regardless of how long the thread has340 // An open PR is unfinished business regardless of how long the thread has
274 // been quiet: review can take days, and hiding the thread would bury the341 // been quiet: review can take days, and hiding the thread would bury the
275 // work waiting on it. A configured merge, a close, or an explicit user342 // work waiting on it. A configured merge, a close, or an explicit user
276 // settle resolves it.343 // settle resolves it.
277 if (options.changeRequestState === "open") return false;344 if (options.changeRequest?.state === "open") return false;
278 if (options.autoSettleAfterDays === null) return false;345 if (options.autoSettleAfterDays === null) return false;
279346
280 const lastActivityAt = threadLastActivityAt(shell);347 const lastActivityAt = threadLastActivityAt(shell);
packages/contracts/src/git.ts+8
...@@ -197,6 +197,14 @@ const VcsStatusChangeRequest = Schema.Struct({...@@ -197,6 +197,14 @@ const VcsStatusChangeRequest = Schema.Struct({
197 baseRef: TrimmedNonEmptyStringSchema,197 baseRef: TrimmedNonEmptyStringSchema,
198 headRef: TrimmedNonEmptyStringSchema,198 headRef: TrimmedNonEmptyStringSchema,
199 state: VcsStatusChangeRequestState,199 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)),
200});208});
201209
202const VcsStatusLocalShape = {210const VcsStatusLocalShape = {