| author | |
| committer | |
| log | f21b47e52d988839a4e488d9fa98344891a69b7f |
| tree | b25e6785c91b24921c5630fec59ed80afb02666c |
| parent | efcf7d1ac03e784bdb26843d61ae0ff81c03cec6 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 14 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; | 14 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; |
| 15 | import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 15 | import type { | 16 | import 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 configured | 490 | // 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"; |
| 13 | import { AsyncResult } from "effect/unstable/reactivity"; | 13 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 14 | import type { EnvironmentId } from "@t3tools/contracts"; | 14 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 15 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; | 15 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; |
| 16 | import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 16 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 17 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 17 | import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; | 18 | import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; |
| 18 | import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; | 19 | import { 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 configured | 422 | // 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"; |
| 5 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; | 5 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; |
| 6 | import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; | 6 | import { |
| 7 | canSnooze, | ||
| 8 | resolveSnoozePresets, | ||
| 9 | type ChangeRequestSettleSource, | ||
| 10 | } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 7 | import type { MenuAction } from "@react-native-menu/menu"; | 11 | import type { MenuAction } from "@react-native-menu/menu"; |
| 8 | import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; | 12 | import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; |
| 9 | import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; | 13 | import { 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 close | 372 | /** 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: { |
| 400 | 404 | ||
| 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]); | ||
| 407 | 415 | ||
| 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"; |
| 9 | import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; | 9 | import type { |
| 10 | ChangeRequestSettleSource, | ||
| 11 | SnoozePreset, | ||
| 12 | } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 10 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 13 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 11 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 14 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 12 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; | 15 | import { 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 on | 326 | /** Environments whose server supports thread.settle/unsettle. Threads on |
| 324 | other environments never classify as settled — the user could neither | 327 | 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 a | 389 | // Visibility parity with web: snooze outranks everything, including a |
| 387 | // pin — a snoozed thread leaves the list until it wakes (or raises its | 390 | // pin — a snoozed thread leaves the list until it wakes (or raises its |
| 388 | // hand). The pin (and its pinOrderKey) survives underneath, so a woken | 391 | // 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"]>; |
| 6 | export interface ThreadPrPresentation { | 6 | export 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 | } |
| 551 | 556 |
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 effect | 4211 | // 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; | ||
| 805 | 804 | ||
| 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 for | 826 | // 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, and | 827 | // 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.state | 2031 | ? 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"; |
| 13 | import type { ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled"; | 13 | import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; |
| 14 | import { ChevronDownIcon } from "lucide-react"; | 14 | import { ChevronDownIcon } from "lucide-react"; |
| 15 | import { | 15 | import { |
| 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"; |
| 14 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 14 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 15 | import { useCallback } from "react"; | 15 | import { 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 | }); |
| 34 | 124 | ||
| ... | @@ -155,7 +245,7 @@ describe("effectiveSettled", () => { | ... | @@ -155,7 +245,7 @@ describe("effectiveSettled", () => { |
| 155 | const changeRequestOptions = | 245 | const changeRequestOptions = |
| 156 | changeRequestState === undefined | 246 | changeRequestState === undefined |
| 157 | ? {} | 247 | ? {} |
| 158 | : { changeRequestState: changeRequestState as ChangeRequestStateLike }; | 248 | : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } }; |
| 159 | 249 | ||
| 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 | }); |
| 193 | 283 | ||
| 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); |
| 204 | 318 | ||
| ... | @@ -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"; |
| 3 | 3 | ||
| 4 | export type ChangeRequestStateLike = "open" | "closed" | "merged"; | 4 | export type ChangeRequestStateLike = "open" | "closed" | "merged"; |
| 5 | 5 | ||
| 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 | */ | ||
| 11 | export 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. */ | ||
| 17 | export 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 | */ | ||
| 29 | function 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 | */ | ||
| 7 | export function changeRequestAutoSettles( | 51 | export 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 | } |
| 13 | 71 | ||
| 14 | const DAY_MS = 24 * 60 * 60 * 1_000; | 72 | const DAY_MS = 24 * 60 * 60 * 1_000; |
| 15 | 73 | ||
| 16 | export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { | 74 | export 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 a | 292 | * 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 | 293 | * 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 server | 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 | ||
| 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-settle | 329 | // "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 has | 340 | // 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 the | 341 | // 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 user | 342 | // 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; |
| 279 | 346 | ||
| 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 | }); |
| 201 | 209 | ||
| 202 | const VcsStatusLocalShape = { | 210 | const VcsStatusLocalShape = { |