| author | |
| committer | |
| log | d5465aebf2746b8d5f327be3b2424d9412a29075 |
| tree | 7d37a16519a6f80a713e8fcf0218576eb114d0cc |
| parent | d715c2e56bb718d2225cc0f07cc65e6c637dc229 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>4 files changed, 618 insertions(+), 36 deletions(-)
apps/web/src/components/ChatView.tsx+9-2| ... | ... | @@ -276,7 +276,10 @@ import { |
| 276 | 276 | shouldShowThreadErrorBanner, |
| 277 | 277 | ThreadErrorBanner, |
| 278 | 278 | } from "./chat/ThreadErrorBanner"; |
| 279 | import { resolveThreadPr } from "./ThreadStatusIndicators"; | |
| 279 | import { | |
| 280 | resolveDisplayedThreadPr, | |
| 281 | threadChangeRequestSnapshotsAtom, | |
| 282 | } from "./ThreadStatusIndicators"; | |
| 280 | 283 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 281 | 284 | import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; |
| 282 | 285 | import { |
| ... | ... | @@ -1596,6 +1599,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1596 | 1599 | [activeThreadEnvironmentId, activeThreadId], |
| 1597 | 1600 | ); |
| 1598 | 1601 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1602 | const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); | |
| 1599 | 1603 | const [timelineAnchor, setTimelineAnchor] = useState<{ |
| 1600 | 1604 | readonly threadKey: string | null; |
| 1601 | 1605 | readonly messageId: MessageId | null; |
| ... | ... | @@ -4124,9 +4128,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 4124 | 4128 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); |
| 4125 | 4129 | const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); |
| 4126 | 4130 | const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); |
| 4127 | const activeThreadPr = resolveThreadPr({ | |
| 4131 | const activeThreadPr = resolveDisplayedThreadPr({ | |
| 4128 | 4132 | threadBranch: activeThread?.branch ?? null, |
| 4129 | 4133 | gitStatus: gitStatusQuery.data ?? null, |
| 4134 | snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, | |
| 4135 | retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, | |
| 4130 | 4136 | }); |
| 4131 | 4137 | // The right panel offers the thread's own change request, so it can only offer it once the |
| 4132 | 4138 | // branch has one; until then the picker says so rather than opening an empty panel. |
| ... | ... | @@ -4208,6 +4214,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4208 | 4214 | activeThreadShell, |
| 4209 | 4215 | autoSettleAfterDays, |
| 4210 | 4216 | autoSettleOnMerge, |
| 4217 | changeRequestSnapshotByKey, | |
| 4211 | 4218 | nowMinute, |
| 4212 | 4219 | supportsSettlement, |
| 4213 | 4220 | ]); |
apps/web/src/components/Sidebar.tsx+49-32| ... | ... | @@ -143,10 +143,15 @@ import { |
| 143 | 143 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| 144 | 144 | import { |
| 145 | 145 | ThreadWorktreeIndicator, |
| 146 | nextThreadChangeRequestSnapshot, | |
| 146 | 147 | prStatusIndicator, |
| 147 | resolveThreadPr, | |
| 148 | resolveDisplayedThreadPr, | |
| 149 | resolveDisplayedThreadPrProvider, | |
| 150 | setThreadChangeRequestSnapshot, | |
| 148 | 151 | settledPrHoverColorClass, |
| 149 | 152 | terminalStatusFromRunningIds, |
| 153 | threadChangeRequestSnapshotsAtom, | |
| 154 | type ThreadChangeRequestSnapshot, | |
| 150 | 155 | type TerminalStatusIndicator, |
| 151 | 156 | } from "./ThreadStatusIndicators"; |
| 152 | 157 | import { |
| ... | ... | @@ -729,11 +734,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 729 | 734 | onUnsnooze: (threadRef: ScopedThreadRef) => void; |
| 730 | 735 | onUnpin: (threadRef: ScopedThreadRef) => void; |
| 731 | 736 | onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; |
| 732 | onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; | |
| 737 | changeRequestSnapshot: ThreadChangeRequestSnapshot | null; | |
| 738 | onChangeRequestSnapshot: ( | |
| 739 | threadKey: string, | |
| 740 | snapshot: ThreadChangeRequestSnapshot | null, | |
| 741 | ) => void; | |
| 733 | 742 | }) { |
| 734 | 743 | const { |
| 735 | 744 | isRenaming, |
| 736 | onChangeRequestState, | |
| 745 | changeRequestSnapshot, | |
| 746 | onChangeRequestSnapshot, | |
| 737 | 747 | onCancelRename, |
| 738 | 748 | onCommitRename, |
| 739 | 749 | onContextMenu, |
| ... | ... | @@ -778,9 +788,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 778 | 788 | }) |
| 779 | 789 | : null, |
| 780 | 790 | ); |
| 781 | const pr = resolveThreadPr({ | |
| 791 | const retainTerminalOnBranchMismatch = thread.worktreePath === null; | |
| 792 | const pr = resolveDisplayedThreadPr({ | |
| 782 | 793 | threadBranch: thread.branch, |
| 783 | 794 | gitStatus: gitStatus.data, |
| 795 | snapshot: changeRequestSnapshot, | |
| 796 | retainTerminalOnBranchMismatch, | |
| 784 | 797 | }); |
| 785 | 798 | const prState = pr?.state ?? null; |
| 786 | 799 | |
| ... | ... | @@ -874,13 +887,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 874 | 887 | activeThreadBranch: thread.branch, |
| 875 | 888 | currentGitBranch: gitStatus.data?.refName ?? null, |
| 876 | 889 | }); |
| 877 | const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); | |
| 890 | const prProvider = resolveDisplayedThreadPrProvider({ | |
| 891 | threadBranch: thread.branch, | |
| 892 | gitStatus: gitStatus.data, | |
| 893 | snapshot: changeRequestSnapshot, | |
| 894 | retainTerminalOnBranchMismatch, | |
| 895 | }); | |
| 896 | const prStatus = prStatusIndicator(pr, prProvider); | |
| 878 | 897 | const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; |
| 879 | // Report the PR state so the parent can apply the configured merge rule | |
| 880 | // and the always-on close rule during partitioning. | |
| 881 | 898 | useEffect(() => { |
| 882 | onChangeRequestState(threadKey, prState); | |
| 883 | }, [onChangeRequestState, prState, threadKey]); | |
| 899 | const nextSnapshot = nextThreadChangeRequestSnapshot({ | |
| 900 | threadBranch: thread.branch, | |
| 901 | gitStatus: gitStatus.data, | |
| 902 | snapshot: changeRequestSnapshot, | |
| 903 | retainTerminalOnBranchMismatch, | |
| 904 | }); | |
| 905 | if (nextSnapshot === undefined) return; | |
| 906 | onChangeRequestSnapshot(threadKey, nextSnapshot); | |
| 907 | }, [ | |
| 908 | changeRequestSnapshot, | |
| 909 | gitStatus.data, | |
| 910 | onChangeRequestSnapshot, | |
| 911 | retainTerminalOnBranchMismatch, | |
| 912 | thread.branch, | |
| 913 | threadKey, | |
| 914 | ]); | |
| 884 | 915 | |
| 885 | 916 | const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; |
| 886 | 917 | const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; |
| ... | ... | @@ -1858,26 +1889,7 @@ export default function Sidebar() { |
| 1858 | 1889 | // fresh clock whenever it recomputes. |
| 1859 | 1890 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); |
| 1860 | 1891 | |
| 1861 | // PR states stream in per-row. The next partition applies the configured | |
| 1862 | // merge rule and the always-on close rule. | |
| 1863 | const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< | |
| 1864 | ReadonlyMap<string, "open" | "closed" | "merged"> | |
| 1865 | >(() => new Map()); | |
| 1866 | const handleChangeRequestState = useCallback( | |
| 1867 | (threadKey: string, state: "open" | "closed" | "merged" | null) => { | |
| 1868 | setChangeRequestStateByKey((current) => { | |
| 1869 | if ((current.get(threadKey) ?? null) === state) return current; | |
| 1870 | const next = new Map(current); | |
| 1871 | if (state === null) { | |
| 1872 | next.delete(threadKey); | |
| 1873 | } else { | |
| 1874 | next.set(threadKey, state); | |
| 1875 | } | |
| 1876 | return next; | |
| 1877 | }); | |
| 1878 | }, | |
| 1879 | [], | |
| 1880 | ); | |
| 1892 | const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); | |
| 1881 | 1893 | |
| 1882 | 1894 | // Project scope: one menu above the list. Scoping filters the list without |
| 1883 | 1895 | // making the header width depend on the number or length of project names. |
| ... | ... | @@ -1993,7 +2005,11 @@ export default function Sidebar() { |
| 1993 | 2005 | const supportsSnooze = |
| 1994 | 2006 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; |
| 1995 | 2007 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); |
| 1996 | const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; | |
| 2008 | const snapshot = changeRequestSnapshotByKey.get(threadKey); | |
| 2009 | const changeRequestState = | |
| 2010 | snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) | |
| 2011 | ? snapshot.pr.state | |
| 2012 | : null; | |
| 1997 | 2013 | // Snooze outranks everything, including a pin: "hide until Tuesday" |
| 1998 | 2014 | // temporarily suspends "keep on top". The pin survives underneath — |
| 1999 | 2015 | // and so does its pinOrderKey, so on wake the thread reappears at |
| ... | ... | @@ -2051,7 +2067,7 @@ export default function Sidebar() { |
| 2051 | 2067 | }, [ |
| 2052 | 2068 | autoSettleAfterDays, |
| 2053 | 2069 | autoSettleOnMerge, |
| 2054 | changeRequestStateByKey, | |
| 2070 | changeRequestSnapshotByKey, | |
| 2055 | 2071 | nowMinute, |
| 2056 | 2072 | scopedProjectKeys, |
| 2057 | 2073 | serverConfigs, |
| ... | ... | @@ -3686,7 +3702,8 @@ export default function Sidebar() { |
| 3686 | 3702 | onUnsnooze={attemptUnsnooze} |
| 3687 | 3703 | onUnpin={attemptUnpin} |
| 3688 | 3704 | onAcknowledgeWoke={acknowledgeWoke} |
| 3689 | onChangeRequestState={handleChangeRequestState} | |
| 3705 | changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} | |
| 3706 | onChangeRequestSnapshot={setThreadChangeRequestSnapshot} | |
| 3690 | 3707 | /> |
| 3691 | 3708 | ); |
| 3692 | 3709 | }; |
apps/web/src/components/ThreadStatusIndicators.test.ts+386-2| ... | ... | @@ -1,10 +1,19 @@ |
| 1 | import type { VcsStatusResult } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 1 | import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; | |
| 2 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; | |
| 3 | import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; | |
| 4 | import { describe, expect, it } from "@effect/vitest"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import { AtomRegistry } from "effect/unstable/reactivity"; | |
| 3 | 7 | |
| 4 | 8 | import { |
| 9 | nextThreadChangeRequestSnapshot, | |
| 5 | 10 | prStatusIndicator, |
| 11 | resolveDisplayedThreadPr, | |
| 12 | resolveDisplayedThreadPrProvider, | |
| 6 | 13 | resolveThreadPr, |
| 7 | 14 | settledPrHoverColorClass, |
| 15 | threadChangeRequestSnapshotsAtom, | |
| 16 | type ThreadChangeRequestSnapshot, | |
| 8 | 17 | } from "./ThreadStatusIndicators"; |
| 9 | 18 | |
| 10 | 19 | function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult { |
| ... | ... | @@ -30,6 +39,25 @@ function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult { |
| 30 | 39 | }; |
| 31 | 40 | } |
| 32 | 41 | |
| 42 | function mergedFeaturePr(): NonNullable<VcsStatusResult["pr"]> { | |
| 43 | return { | |
| 44 | number: 42, | |
| 45 | title: "Feature PR", | |
| 46 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 47 | baseRef: "main", | |
| 48 | headRef: "feature/current", | |
| 49 | state: "merged", | |
| 50 | }; | |
| 51 | } | |
| 52 | ||
| 53 | function snapshotFor( | |
| 54 | branch: string, | |
| 55 | pr: NonNullable<VcsStatusResult["pr"]>, | |
| 56 | sourceControlProvider?: VcsStatusResult["sourceControlProvider"], | |
| 57 | ): ThreadChangeRequestSnapshot { | |
| 58 | return { branch, pr, sourceControlProvider }; | |
| 59 | } | |
| 60 | ||
| 33 | 61 | describe("resolveThreadPr", () => { |
| 34 | 62 | it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { |
| 35 | 63 | expect( |
| ... | ... | @@ -70,6 +98,362 @@ describe("resolveThreadPr", () => { |
| 70 | 98 | }); |
| 71 | 99 | }); |
| 72 | 100 | |
| 101 | describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { | |
| 102 | const featureBranch = "feature/current"; | |
| 103 | const mergedPr = mergedFeaturePr(); | |
| 104 | const provider = { | |
| 105 | kind: "github" as const, | |
| 106 | name: "GitHub", | |
| 107 | baseUrl: "https://github.com", | |
| 108 | }; | |
| 109 | ||
| 110 | it("returns the live merged PR when the checkout matches the feature branch", () => { | |
| 111 | const gitStatus = status({ | |
| 112 | refName: featureBranch, | |
| 113 | pr: mergedPr, | |
| 114 | sourceControlProvider: provider, | |
| 115 | }); | |
| 116 | ||
| 117 | expect( | |
| 118 | resolveDisplayedThreadPr({ | |
| 119 | threadBranch: featureBranch, | |
| 120 | gitStatus, | |
| 121 | snapshot: undefined, | |
| 122 | retainTerminalOnBranchMismatch: true, | |
| 123 | }), | |
| 124 | ).toBe(mergedPr); | |
| 125 | expect( | |
| 126 | resolveDisplayedThreadPrProvider({ | |
| 127 | threadBranch: featureBranch, | |
| 128 | gitStatus, | |
| 129 | snapshot: undefined, | |
| 130 | retainTerminalOnBranchMismatch: true, | |
| 131 | }), | |
| 132 | ).toEqual(provider); | |
| 133 | }); | |
| 134 | ||
| 135 | it("after caching a merged PR, resolves main status back to the cached feature PR", () => { | |
| 136 | const matchingStatus = status({ | |
| 137 | refName: featureBranch, | |
| 138 | pr: mergedPr, | |
| 139 | sourceControlProvider: provider, | |
| 140 | }); | |
| 141 | const cached = nextThreadChangeRequestSnapshot({ | |
| 142 | threadBranch: featureBranch, | |
| 143 | gitStatus: matchingStatus, | |
| 144 | snapshot: undefined, | |
| 145 | retainTerminalOnBranchMismatch: true, | |
| 146 | }); | |
| 147 | expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); | |
| 148 | ||
| 149 | const mainStatus = status({ | |
| 150 | refName: "main", | |
| 151 | isDefaultRef: true, | |
| 152 | pr: { | |
| 153 | number: 99, | |
| 154 | title: "Unrelated main PR", | |
| 155 | url: "https://github.com/pingdotgg/t3code/pull/99", | |
| 156 | baseRef: "main", | |
| 157 | headRef: "main", | |
| 158 | state: "open", | |
| 159 | }, | |
| 160 | sourceControlProvider: provider, | |
| 161 | }); | |
| 162 | ||
| 163 | expect( | |
| 164 | resolveDisplayedThreadPr({ | |
| 165 | threadBranch: featureBranch, | |
| 166 | gitStatus: mainStatus, | |
| 167 | snapshot: cached as ThreadChangeRequestSnapshot, | |
| 168 | retainTerminalOnBranchMismatch: true, | |
| 169 | }), | |
| 170 | ).toEqual(mergedPr); | |
| 171 | expect( | |
| 172 | resolveDisplayedThreadPrProvider({ | |
| 173 | threadBranch: featureBranch, | |
| 174 | gitStatus: mainStatus, | |
| 175 | snapshot: cached as ThreadChangeRequestSnapshot, | |
| 176 | retainTerminalOnBranchMismatch: true, | |
| 177 | }), | |
| 178 | ).toEqual(provider); | |
| 179 | }); | |
| 180 | ||
| 181 | it("never attaches a PR reported by main to the feature thread", () => { | |
| 182 | const mainPr = { | |
| 183 | number: 99, | |
| 184 | title: "Unrelated main PR", | |
| 185 | url: "https://github.com/pingdotgg/t3code/pull/99", | |
| 186 | baseRef: "develop", | |
| 187 | headRef: "main", | |
| 188 | state: "merged" as const, | |
| 189 | }; | |
| 190 | expect( | |
| 191 | resolveDisplayedThreadPr({ | |
| 192 | threadBranch: featureBranch, | |
| 193 | gitStatus: status({ refName: "main", pr: mainPr }), | |
| 194 | snapshot: undefined, | |
| 195 | retainTerminalOnBranchMismatch: true, | |
| 196 | }), | |
| 197 | ).toBeNull(); | |
| 198 | expect( | |
| 199 | nextThreadChangeRequestSnapshot({ | |
| 200 | threadBranch: featureBranch, | |
| 201 | gitStatus: status({ refName: "main", pr: mainPr }), | |
| 202 | snapshot: undefined, | |
| 203 | retainTerminalOnBranchMismatch: true, | |
| 204 | }), | |
| 205 | ).toBeNull(); | |
| 206 | }); | |
| 207 | ||
| 208 | it("does not show a cached open PR across a branch mismatch", () => { | |
| 209 | const openSnapshot = snapshotFor(featureBranch, { | |
| 210 | ...mergedPr, | |
| 211 | state: "open", | |
| 212 | title: "Still open", | |
| 213 | }); | |
| 214 | ||
| 215 | expect( | |
| 216 | resolveDisplayedThreadPr({ | |
| 217 | threadBranch: featureBranch, | |
| 218 | gitStatus: status({ refName: "main", pr: null }), | |
| 219 | snapshot: openSnapshot, | |
| 220 | retainTerminalOnBranchMismatch: true, | |
| 221 | }), | |
| 222 | ).toBeNull(); | |
| 223 | }); | |
| 224 | ||
| 225 | it("retains a cached closed PR across a branch mismatch", () => { | |
| 226 | const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; | |
| 227 | const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); | |
| 228 | ||
| 229 | expect( | |
| 230 | resolveDisplayedThreadPr({ | |
| 231 | threadBranch: featureBranch, | |
| 232 | gitStatus: status({ refName: "main", pr: null }), | |
| 233 | snapshot: closedSnapshot, | |
| 234 | retainTerminalOnBranchMismatch: true, | |
| 235 | }), | |
| 236 | ).toEqual(closedPr); | |
| 237 | }); | |
| 238 | ||
| 239 | it("does not retain or display a terminal PR when a worktree switches branches", () => { | |
| 240 | const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 241 | const mismatchedStatus = status({ refName: "feature/other", pr: null }); | |
| 242 | ||
| 243 | expect( | |
| 244 | resolveDisplayedThreadPr({ | |
| 245 | threadBranch: featureBranch, | |
| 246 | gitStatus: mismatchedStatus, | |
| 247 | snapshot: terminalSnapshot, | |
| 248 | retainTerminalOnBranchMismatch: false, | |
| 249 | }), | |
| 250 | ).toBeNull(); | |
| 251 | expect( | |
| 252 | resolveDisplayedThreadPrProvider({ | |
| 253 | threadBranch: featureBranch, | |
| 254 | gitStatus: mismatchedStatus, | |
| 255 | snapshot: terminalSnapshot, | |
| 256 | retainTerminalOnBranchMismatch: false, | |
| 257 | }), | |
| 258 | ).toBeUndefined(); | |
| 259 | expect( | |
| 260 | nextThreadChangeRequestSnapshot({ | |
| 261 | threadBranch: featureBranch, | |
| 262 | gitStatus: mismatchedStatus, | |
| 263 | snapshot: terminalSnapshot, | |
| 264 | retainTerminalOnBranchMismatch: false, | |
| 265 | }), | |
| 266 | ).toBeNull(); | |
| 267 | }); | |
| 268 | ||
| 269 | it("retains a local terminal snapshot when thread metadata follows the new branch", () => { | |
| 270 | const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); | |
| 271 | ||
| 272 | expect( | |
| 273 | resolveDisplayedThreadPr({ | |
| 274 | threadBranch: featureBranch, | |
| 275 | gitStatus: status({ refName: "main", pr: null }), | |
| 276 | snapshot: otherBranchSnapshot, | |
| 277 | retainTerminalOnBranchMismatch: true, | |
| 278 | }), | |
| 279 | ).toEqual(mergedPr); | |
| 280 | }); | |
| 281 | ||
| 282 | it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { | |
| 283 | const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 284 | ||
| 285 | expect( | |
| 286 | nextThreadChangeRequestSnapshot({ | |
| 287 | threadBranch: "main", | |
| 288 | gitStatus: status({ refName: "main", pr: null }), | |
| 289 | snapshot: terminalSnapshot, | |
| 290 | retainTerminalOnBranchMismatch: true, | |
| 291 | }), | |
| 292 | ).toBeUndefined(); | |
| 293 | expect( | |
| 294 | resolveDisplayedThreadPr({ | |
| 295 | threadBranch: "main", | |
| 296 | gitStatus: status({ refName: "main", pr: null }), | |
| 297 | snapshot: terminalSnapshot, | |
| 298 | retainTerminalOnBranchMismatch: true, | |
| 299 | }), | |
| 300 | ).toEqual(mergedPr); | |
| 301 | }); | |
| 302 | ||
| 303 | it("clears an open snapshot when a local thread moves to a branch with no PR", () => { | |
| 304 | const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); | |
| 305 | ||
| 306 | expect( | |
| 307 | nextThreadChangeRequestSnapshot({ | |
| 308 | threadBranch: "main", | |
| 309 | gitStatus: status({ refName: "main", pr: null }), | |
| 310 | snapshot: openSnapshot, | |
| 311 | retainTerminalOnBranchMismatch: true, | |
| 312 | }), | |
| 313 | ).toBeNull(); | |
| 314 | }); | |
| 315 | ||
| 316 | it("clears an open snapshot when a local checkout moves to a different branch", () => { | |
| 317 | const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); | |
| 318 | ||
| 319 | expect( | |
| 320 | nextThreadChangeRequestSnapshot({ | |
| 321 | threadBranch: featureBranch, | |
| 322 | gitStatus: status({ refName: "main", pr: null }), | |
| 323 | snapshot: openSnapshot, | |
| 324 | retainTerminalOnBranchMismatch: true, | |
| 325 | }), | |
| 326 | ).toBeNull(); | |
| 327 | }); | |
| 328 | ||
| 329 | it("clears a retained snapshot when the thread branch is cleared", () => { | |
| 330 | const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 331 | ||
| 332 | expect( | |
| 333 | nextThreadChangeRequestSnapshot({ | |
| 334 | threadBranch: null, | |
| 335 | gitStatus: status({ refName: "main", pr: null }), | |
| 336 | snapshot: terminalSnapshot, | |
| 337 | retainTerminalOnBranchMismatch: true, | |
| 338 | }), | |
| 339 | ).toBeNull(); | |
| 340 | expect( | |
| 341 | resolveDisplayedThreadPr({ | |
| 342 | threadBranch: null, | |
| 343 | gitStatus: status({ refName: "main", pr: null }), | |
| 344 | snapshot: terminalSnapshot, | |
| 345 | retainTerminalOnBranchMismatch: true, | |
| 346 | }), | |
| 347 | ).toBeNull(); | |
| 348 | expect( | |
| 349 | resolveDisplayedThreadPrProvider({ | |
| 350 | threadBranch: null, | |
| 351 | gitStatus: status({ refName: "main", pr: null }), | |
| 352 | snapshot: terminalSnapshot, | |
| 353 | retainTerminalOnBranchMismatch: true, | |
| 354 | }), | |
| 355 | ).toBeUndefined(); | |
| 356 | }); | |
| 357 | ||
| 358 | it("does not erase a terminal snapshot when VCS data is missing", () => { | |
| 359 | const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 360 | ||
| 361 | expect( | |
| 362 | nextThreadChangeRequestSnapshot({ | |
| 363 | threadBranch: featureBranch, | |
| 364 | gitStatus: null, | |
| 365 | snapshot: terminalSnapshot, | |
| 366 | retainTerminalOnBranchMismatch: true, | |
| 367 | }), | |
| 368 | ).toBeUndefined(); | |
| 369 | expect( | |
| 370 | resolveDisplayedThreadPr({ | |
| 371 | threadBranch: featureBranch, | |
| 372 | gitStatus: null, | |
| 373 | snapshot: terminalSnapshot, | |
| 374 | retainTerminalOnBranchMismatch: true, | |
| 375 | }), | |
| 376 | ).toEqual(mergedPr); | |
| 377 | }); | |
| 378 | ||
| 379 | it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => { | |
| 380 | const matchingStatus = status({ | |
| 381 | refName: featureBranch, | |
| 382 | pr: mergedPr, | |
| 383 | sourceControlProvider: provider, | |
| 384 | }); | |
| 385 | const cached = nextThreadChangeRequestSnapshot({ | |
| 386 | threadBranch: featureBranch, | |
| 387 | gitStatus: matchingStatus, | |
| 388 | snapshot: undefined, | |
| 389 | retainTerminalOnBranchMismatch: true, | |
| 390 | }); | |
| 391 | expect(cached).not.toBeNull(); | |
| 392 | expect(cached).not.toBeUndefined(); | |
| 393 | ||
| 394 | const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); | |
| 395 | const displayed = resolveDisplayedThreadPr({ | |
| 396 | threadBranch: "main", | |
| 397 | gitStatus: mainStatus, | |
| 398 | snapshot: cached as ThreadChangeRequestSnapshot, | |
| 399 | retainTerminalOnBranchMismatch: true, | |
| 400 | }); | |
| 401 | expect(displayed?.state).toBe("merged"); | |
| 402 | ||
| 403 | const shell = { | |
| 404 | id: ThreadId.make("thread-1"), | |
| 405 | projectId: ProjectId.make("project-1"), | |
| 406 | title: "Feature thread", | |
| 407 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 408 | runtimeMode: "full-access", | |
| 409 | interactionMode: "default", | |
| 410 | branch: "main", | |
| 411 | worktreePath: null, | |
| 412 | latestTurn: null, | |
| 413 | session: null, | |
| 414 | createdAt: "2026-04-09T00:00:00.000Z", | |
| 415 | updatedAt: "2026-04-09T00:00:00.000Z", | |
| 416 | archivedAt: null, | |
| 417 | settledAt: null, | |
| 418 | settledOverride: null, | |
| 419 | latestUserMessageAt: "2026-04-09T00:00:00.000Z", | |
| 420 | hasPendingApprovals: false, | |
| 421 | hasPendingUserInput: false, | |
| 422 | hasActionableProposedPlan: false, | |
| 423 | } as OrchestrationThreadShell; | |
| 424 | ||
| 425 | expect( | |
| 426 | effectiveSettled(shell, { | |
| 427 | now: "2026-04-10T00:00:00.000Z", | |
| 428 | autoSettleAfterDays: null, | |
| 429 | changeRequestState: displayed?.state ?? null, | |
| 430 | }), | |
| 431 | ).toBe(true); | |
| 432 | }); | |
| 433 | }); | |
| 434 | ||
| 435 | describe("threadChangeRequestSnapshotsAtom", () => { | |
| 436 | it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => | |
| 437 | Effect.gen(function* () { | |
| 438 | const registry = AtomRegistry.make(); | |
| 439 | const threadKey = "environment-1:thread-1"; | |
| 440 | const snapshot = snapshotFor("feature/current", mergedFeaturePr()); | |
| 441 | ||
| 442 | const unmount = registry.mount(threadChangeRequestSnapshotsAtom); | |
| 443 | registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); | |
| 444 | unmount(); | |
| 445 | ||
| 446 | yield* Effect.yieldNow; | |
| 447 | ||
| 448 | const remount = registry.mount(threadChangeRequestSnapshotsAtom); | |
| 449 | expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); | |
| 450 | ||
| 451 | remount(); | |
| 452 | registry.dispose(); | |
| 453 | }), | |
| 454 | ); | |
| 455 | }); | |
| 456 | ||
| 73 | 457 | describe("prStatusIndicator", () => { |
| 74 | 458 | it("formats PR tooltips with number, uppercase status, and title", () => { |
| 75 | 459 | expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ |
apps/web/src/components/ThreadStatusIndicators.tsx+174| ... | ... | @@ -4,8 +4,10 @@ import { |
| 4 | 4 | scopeThreadRef, |
| 5 | 5 | } from "@t3tools/client-runtime/environment"; |
| 6 | 6 | import type { VcsStatusResult } from "@t3tools/contracts"; |
| 7 | import { Atom } from "effect/unstable/reactivity"; | |
| 7 | 8 | import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; |
| 8 | 9 | import { useMemo } from "react"; |
| 10 | import { appAtomRegistry } from "../rpc/atomRegistry"; | |
| 9 | 11 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 10 | 12 | import { useProject } from "../state/entities"; |
| 11 | 13 | import { useEnvironmentQuery } from "../state/query"; |
| ... | ... | @@ -126,6 +128,178 @@ export function resolveThreadPr(input: { |
| 126 | 128 | return gitStatus.pr ?? null; |
| 127 | 129 | } |
| 128 | 130 | |
| 131 | /** | |
| 132 | * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement | |
| 133 | * partitions move them, so terminal PR metadata must live above the row. | |
| 134 | */ | |
| 135 | export interface ThreadChangeRequestSnapshot { | |
| 136 | readonly branch: string; | |
| 137 | readonly pr: NonNullable<ThreadPr>; | |
| 138 | readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; | |
| 139 | } | |
| 140 | ||
| 141 | export const threadChangeRequestSnapshotsAtom = Atom.make< | |
| 142 | ReadonlyMap<string, ThreadChangeRequestSnapshot> | |
| 143 | >(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); | |
| 144 | ||
| 145 | function isTerminalChangeRequestState( | |
| 146 | state: NonNullable<ThreadPr>["state"], | |
| 147 | ): state is "merged" | "closed" { | |
| 148 | return state === "merged" || state === "closed"; | |
| 149 | } | |
| 150 | ||
| 151 | function sourceControlProvidersEqual( | |
| 152 | left: VcsStatusResult["sourceControlProvider"] | undefined, | |
| 153 | right: VcsStatusResult["sourceControlProvider"] | undefined, | |
| 154 | ): boolean { | |
| 155 | if (left === right) return true; | |
| 156 | if (left == null || right == null) return left == null && right == null; | |
| 157 | return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; | |
| 158 | } | |
| 159 | ||
| 160 | export function threadChangeRequestSnapshotsEqual( | |
| 161 | left: ThreadChangeRequestSnapshot, | |
| 162 | right: ThreadChangeRequestSnapshot, | |
| 163 | ): boolean { | |
| 164 | return ( | |
| 165 | left.branch === right.branch && | |
| 166 | left.pr.number === right.pr.number && | |
| 167 | left.pr.title === right.pr.title && | |
| 168 | left.pr.url === right.pr.url && | |
| 169 | left.pr.baseRef === right.pr.baseRef && | |
| 170 | left.pr.headRef === right.pr.headRef && | |
| 171 | left.pr.state === right.pr.state && | |
| 172 | sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) | |
| 173 | ); | |
| 174 | } | |
| 175 | ||
| 176 | export function setThreadChangeRequestSnapshot( | |
| 177 | threadKey: string, | |
| 178 | snapshot: ThreadChangeRequestSnapshot | null, | |
| 179 | ): void { | |
| 180 | appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { | |
| 181 | const existing = current.get(threadKey); | |
| 182 | if (snapshot === null) { | |
| 183 | if (existing === undefined) return [false, current]; | |
| 184 | const next = new Map(current); | |
| 185 | next.delete(threadKey); | |
| 186 | return [true, next]; | |
| 187 | } | |
| 188 | if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { | |
| 189 | return [false, current]; | |
| 190 | } | |
| 191 | const next = new Map(current); | |
| 192 | next.set(threadKey, snapshot); | |
| 193 | return [true, next]; | |
| 194 | }); | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Authoritative snapshot update from live VCS status. | |
| 199 | * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone | |
| 200 | * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear | |
| 201 | * - snapshot: matching branch reports a PR — store/replace | |
| 202 | */ | |
| 203 | export function nextThreadChangeRequestSnapshot(input: { | |
| 204 | threadBranch: string | null; | |
| 205 | gitStatus: VcsStatusResult | null; | |
| 206 | snapshot: ThreadChangeRequestSnapshot | null | undefined; | |
| 207 | retainTerminalOnBranchMismatch: boolean; | |
| 208 | }): ThreadChangeRequestSnapshot | null | undefined { | |
| 209 | const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; | |
| 210 | if (gitStatus === null) { | |
| 211 | return undefined; | |
| 212 | } | |
| 213 | if (threadBranch === null) { | |
| 214 | return null; | |
| 215 | } | |
| 216 | if (gitStatus.refName !== threadBranch) { | |
| 217 | return retainTerminalOnBranchMismatch && | |
| 218 | snapshot != null && | |
| 219 | isTerminalChangeRequestState(snapshot.pr.state) | |
| 220 | ? undefined | |
| 221 | : null; | |
| 222 | } | |
| 223 | if (gitStatus.pr == null) { | |
| 224 | if ( | |
| 225 | retainTerminalOnBranchMismatch && | |
| 226 | snapshot != null && | |
| 227 | isTerminalChangeRequestState(snapshot.pr.state) | |
| 228 | ) { | |
| 229 | return undefined; | |
| 230 | } | |
| 231 | return null; | |
| 232 | } | |
| 233 | return { | |
| 234 | branch: threadBranch, | |
| 235 | pr: gitStatus.pr, | |
| 236 | sourceControlProvider: gitStatus.sourceControlProvider, | |
| 237 | }; | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * Live PR when the checkout matches the thread branch; otherwise, for local | |
| 242 | * checkouts only, a cached merged/closed PR for the thread. Local thread | |
| 243 | * metadata follows the shared checkout, so the cached branch intentionally | |
| 244 | * survives that metadata changing to the newly checked-out branch. Open PRs | |
| 245 | * are never retained — their state can still change. | |
| 246 | */ | |
| 247 | export function resolveDisplayedThreadPr(input: { | |
| 248 | threadBranch: string | null; | |
| 249 | gitStatus: VcsStatusResult | null; | |
| 250 | snapshot: ThreadChangeRequestSnapshot | null | undefined; | |
| 251 | retainTerminalOnBranchMismatch: boolean; | |
| 252 | }): ThreadPr | null { | |
| 253 | const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; | |
| 254 | if ( | |
| 255 | threadBranch !== null && | |
| 256 | gitStatus !== null && | |
| 257 | gitStatus.refName === threadBranch && | |
| 258 | gitStatus.pr != null | |
| 259 | ) { | |
| 260 | return gitStatus.pr; | |
| 261 | } | |
| 262 | ||
| 263 | if ( | |
| 264 | threadBranch !== null && | |
| 265 | retainTerminalOnBranchMismatch && | |
| 266 | snapshot != null && | |
| 267 | isTerminalChangeRequestState(snapshot.pr.state) | |
| 268 | ) { | |
| 269 | return snapshot.pr; | |
| 270 | } | |
| 271 | ||
| 272 | return null; | |
| 273 | } | |
| 274 | ||
| 275 | export function resolveDisplayedThreadPrProvider(input: { | |
| 276 | threadBranch: string | null; | |
| 277 | gitStatus: VcsStatusResult | null; | |
| 278 | snapshot: ThreadChangeRequestSnapshot | null | undefined; | |
| 279 | retainTerminalOnBranchMismatch: boolean; | |
| 280 | }): VcsStatusResult["sourceControlProvider"] | undefined { | |
| 281 | const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; | |
| 282 | if ( | |
| 283 | threadBranch !== null && | |
| 284 | gitStatus !== null && | |
| 285 | gitStatus.refName === threadBranch && | |
| 286 | gitStatus.pr != null | |
| 287 | ) { | |
| 288 | return gitStatus.sourceControlProvider; | |
| 289 | } | |
| 290 | ||
| 291 | if ( | |
| 292 | threadBranch !== null && | |
| 293 | retainTerminalOnBranchMismatch && | |
| 294 | snapshot != null && | |
| 295 | isTerminalChangeRequestState(snapshot.pr.state) | |
| 296 | ) { | |
| 297 | return snapshot.sourceControlProvider; | |
| 298 | } | |
| 299 | ||
| 300 | return undefined; | |
| 301 | } | |
| 302 | ||
| 129 | 303 | export function terminalStatusFromRunningIds( |
| 130 | 304 | runningTerminalIds: ReadonlyArray<string>, |
| 131 | 305 | ): TerminalStatusIndicator | null { |