| author | |
| committer | |
| log | fc53b273039cf5e0a932582b1fbb56e7223ad4ab |
| tree | 93f3232e11f2e9f422523efbb820cb88bfdd2d91 |
| parent | 6866fd6b5ccff82333ad4386ce68cdec03531b68 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Theo Browne <me@t3.gg>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>6 files changed, 181 insertions(+), 34 deletions(-)
apps/mobile/src/state/use-thread-pr.ts+75-18| ... | @@ -1,15 +1,33 @@ | ... | @@ -1,15 +1,33 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; | ||
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 3 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { | 4 | import { |
| 3 | createLinkedPullRequestDetailAtomFamily, | 5 | createLinkedPullRequestDetailAtomFamily, |
| 4 | pullRequestDetailToVcsStatus, | 6 | pullRequestDetailToVcsStatus, |
| 5 | } from "@t3tools/client-runtime/state/pull-requests"; | 7 | } from "@t3tools/client-runtime/state/pull-requests"; |
| 8 | import { Atom } from "effect/unstable/reactivity"; | ||
| 9 | import { useCallback, useEffect, useMemo } from "react"; | ||
| 6 | 10 | ||
| 7 | import { connectionAtomRuntime } from "../connection/runtime"; | 11 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 12 | import { appAtomRegistry } from "./atom-registry"; | ||
| 8 | import { useEnvironmentQuery } from "./query"; | 13 | import { useEnvironmentQuery } from "./query"; |
| 9 | import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; | 14 | import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; |
| 10 | import { vcsEnvironment } from "./vcs"; | 15 | import { vcsEnvironment } from "./vcs"; |
| 11 | 16 | ||
| 12 | const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); | 17 | const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); |
| 18 | const MAX_THREAD_PR_SNAPSHOTS = 500; | ||
| 19 | |||
| 20 | interface ThreadPrSnapshot { | ||
| 21 | readonly identity: string; | ||
| 22 | readonly presentation: ThreadPrPresentation; | ||
| 23 | } | ||
| 24 | |||
| 25 | // One bounded cache survives row virtualization without retaining one live | ||
| 26 | // atom for every thread, branch, directory, or linked pull request ever seen. | ||
| 27 | const threadPrSnapshotsAtom = Atom.make<ReadonlyMap<string, ThreadPrSnapshot>>(new Map()).pipe( | ||
| 28 | Atom.keepAlive, | ||
| 29 | Atom.withLabel("mobile:thread-pr-snapshots"), | ||
| 30 | ); | ||
| 13 | 31 | ||
| 14 | export { | 32 | export { |
| 15 | presentThreadPr, | 33 | presentThreadPr, |
| ... | @@ -28,6 +46,19 @@ export function useThreadPr( | ... | @@ -28,6 +46,19 @@ export function useThreadPr( |
| 28 | projectCwd: string | null, | 46 | projectCwd: string | null, |
| 29 | ): ThreadPrPresentation | null { | 47 | ): ThreadPrPresentation | null { |
| 30 | const cwd = thread.worktreePath ?? projectCwd; | 48 | const cwd = thread.worktreePath ?? projectCwd; |
| 49 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); | ||
| 50 | const snapshotIdentity = JSON.stringify( | ||
| 51 | thread.linkedPullRequest ?? { branch: thread.branch, cwd }, | ||
| 52 | ); | ||
| 53 | // Select this row's entry so writes for other rows do not re-render it. | ||
| 54 | const snapshotEntry = useAtomValue( | ||
| 55 | threadPrSnapshotsAtom, | ||
| 56 | useCallback( | ||
| 57 | (current: ReadonlyMap<string, ThreadPrSnapshot>) => current.get(threadKey), | ||
| 58 | [threadKey], | ||
| 59 | ), | ||
| 60 | ); | ||
| 61 | const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null; | ||
| 31 | const gitStatus = useEnvironmentQuery( | 62 | const gitStatus = useEnvironmentQuery( |
| 32 | thread.linkedPullRequest == null && thread.branch !== null && cwd !== null | 63 | thread.linkedPullRequest == null && thread.branch !== null && cwd !== null |
| 33 | ? vcsEnvironment.status({ | 64 | ? vcsEnvironment.status({ |
| ... | @@ -49,23 +80,49 @@ export function useThreadPr( | ... | @@ -49,23 +80,49 @@ export function useThreadPr( |
| 49 | }), | 80 | }), |
| 50 | ); | 81 | ); |
| 51 | 82 | ||
| 52 | if (thread.linkedPullRequest != null) { | 83 | const live = useMemo<ThreadPrPresentation | null | undefined>(() => { |
| 53 | const detail = linkedPullRequest.data; | 84 | if (thread.linkedPullRequest != null) { |
| 54 | return detail === null | 85 | const detail = linkedPullRequest.data; |
| 55 | ? null | 86 | return detail === null |
| 56 | : presentThreadPr(pullRequestDetailToVcsStatus(detail), { | 87 | ? undefined |
| 57 | kind: detail.provider, | 88 | : presentThreadPr(pullRequestDetailToVcsStatus(detail), { |
| 58 | name: detail.provider, | 89 | kind: detail.provider, |
| 59 | baseUrl: "", | 90 | name: detail.provider, |
| 60 | }); | 91 | baseUrl: "", |
| 61 | } | 92 | }); |
| 93 | } | ||
| 94 | |||
| 95 | const status = gitStatus.data; | ||
| 96 | if (thread.branch === null) return null; | ||
| 97 | if (status === null) return undefined; | ||
| 98 | if (status.refName !== thread.branch || !status.pr) return null; | ||
| 99 | return presentThreadPr(status.pr, status.sourceControlProvider); | ||
| 100 | }, [gitStatus.data, linkedPullRequest.data, thread.branch, thread.linkedPullRequest]); | ||
| 101 | |||
| 102 | useEffect(() => { | ||
| 103 | if (live === undefined) return; | ||
| 104 | appAtomRegistry.modify(threadPrSnapshotsAtom, (current) => { | ||
| 105 | const existing = current.get(threadKey); | ||
| 106 | if (live === null) { | ||
| 107 | if (existing === undefined) return [false, current]; | ||
| 108 | const next = new Map(current); | ||
| 109 | next.delete(threadKey); | ||
| 110 | return [true, next]; | ||
| 111 | } | ||
| 112 | if (existing?.identity === snapshotIdentity && existing.presentation === live) { | ||
| 113 | return [false, current]; | ||
| 114 | } | ||
| 115 | const next = new Map(current); | ||
| 116 | next.delete(threadKey); | ||
| 117 | next.set(threadKey, { identity: snapshotIdentity, presentation: live }); | ||
| 118 | while (next.size > MAX_THREAD_PR_SNAPSHOTS) { | ||
| 119 | const oldestKey = next.keys().next().value; | ||
| 120 | if (oldestKey === undefined) break; | ||
| 121 | next.delete(oldestKey); | ||
| 122 | } | ||
| 123 | return [true, next]; | ||
| 124 | }); | ||
| 125 | }, [live, snapshotIdentity, threadKey]); | ||
| 62 | 126 | ||
| 63 | const status = gitStatus.data; | 127 | return live === undefined ? snapshot : live; |
| 64 | if (status === null || thread.branch === null || status.refName !== thread.branch) { | ||
| 65 | return null; | ||
| 66 | } | ||
| 67 | if (!status.pr) { | ||
| 68 | return null; | ||
| 69 | } | ||
| 70 | return presentThreadPr(status.pr, status.sourceControlProvider); | ||
| 71 | } | 128 | } |
apps/web/src/components/LegacySidebar.tsx+21-6| ... | @@ -185,6 +185,8 @@ import { | ... | @@ -185,6 +185,8 @@ import { |
| 185 | orderItemsByPreferredIds, | 185 | orderItemsByPreferredIds, |
| 186 | shouldClearThreadSelectionOnMouseDown, | 186 | shouldClearThreadSelectionOnMouseDown, |
| 187 | sortProjectsForSidebar, | 187 | sortProjectsForSidebar, |
| 188 | useRetainedValue, | ||
| 189 | useSidebarRowSubscriptionLease, | ||
| 188 | useThreadJumpHintVisibility, | 190 | useThreadJumpHintVisibility, |
| 189 | ThreadStatusPill, | 191 | ThreadStatusPill, |
| 190 | } from "./Sidebar.logic"; | 192 | } from "./Sidebar.logic"; |
| ... | @@ -376,6 +378,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr | ... | @@ -376,6 +378,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 376 | } = props; | 378 | } = props; |
| 377 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); | 379 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); |
| 378 | const threadKey = scopedThreadKey(threadRef); | 380 | const threadKey = scopedThreadKey(threadRef); |
| 381 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); | ||
| 379 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); | 382 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); |
| 380 | const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); | 383 | const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); |
| 381 | const runningTerminalIds = useThreadRunningTerminalIds({ | 384 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| ... | @@ -417,7 +420,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr | ... | @@ -417,7 +420,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 417 | const threadProjectCwd = threadProject?.workspaceRoot ?? null; | 420 | const threadProjectCwd = threadProject?.workspaceRoot ?? null; |
| 418 | const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; | 421 | const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; |
| 419 | const gitStatus = useEnvironmentQuery( | 422 | const gitStatus = useEnvironmentQuery( |
| 420 | thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null | 423 | leaseLiveStatus && thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null |
| 421 | ? vcsEnvironment.status({ | 424 | ? vcsEnvironment.status({ |
| 422 | environmentId: thread.environmentId, | 425 | environmentId: thread.environmentId, |
| 423 | input: { cwd: gitCwd }, | 426 | input: { cwd: gitCwd }, |
| ... | @@ -459,16 +462,27 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr | ... | @@ -459,16 +462,27 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 459 | }, | 462 | }, |
| 460 | }); | 463 | }); |
| 461 | const linkedPullRequestStatus = useLinkedThreadPullRequest( | 464 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| 462 | thread.environmentId, | 465 | leaseLiveStatus ? thread.environmentId : null, |
| 463 | thread.linkedPullRequest, | 466 | leaseLiveStatus ? thread.linkedPullRequest : null, |
| 467 | ); | ||
| 468 | const visibleGitStatus = useRetainedValue( | ||
| 469 | JSON.stringify([thread.environmentId, gitCwd]), | ||
| 470 | gitStatus.data, | ||
| 471 | ); | ||
| 472 | const visibleLinkedPullRequestStatus = useRetainedValue( | ||
| 473 | thread.linkedPullRequest === null | ||
| 474 | ? null | ||
| 475 | : JSON.stringify([thread.environmentId, thread.linkedPullRequest]), | ||
| 476 | linkedPullRequestStatus, | ||
| 464 | ); | 477 | ); |
| 465 | const pr = | 478 | const pr = |
| 466 | thread.linkedPullRequest == null | 479 | thread.linkedPullRequest == null |
| 467 | ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) | 480 | ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: visibleGitStatus }) |
| 468 | : (linkedPullRequestStatus?.pr ?? null); | 481 | : (visibleLinkedPullRequestStatus?.pr ?? null); |
| 469 | const prStatus = prStatusIndicator( | 482 | const prStatus = prStatusIndicator( |
| 470 | pr, | 483 | pr, |
| 471 | linkedPullRequestStatus?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, | 484 | visibleLinkedPullRequestStatus?.sourceControlProvider ?? |
| 485 | visibleGitStatus?.sourceControlProvider, | ||
| 472 | ); | 486 | ); |
| 473 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); | 487 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 474 | const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; | 488 | const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; |
| ... | @@ -681,6 +695,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr | ... | @@ -681,6 +695,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 681 | 695 | ||
| 682 | return ( | 696 | return ( |
| 683 | <SidebarMenuSubItem | 697 | <SidebarMenuSubItem |
| 698 | ref={rowRef} | ||
| 684 | className="w-full" | 699 | className="w-full" |
| 685 | data-thread-item | 700 | data-thread-item |
| 686 | onMouseLeave={handleMouseLeave} | 701 | onMouseLeave={handleMouseLeave} |
apps/web/src/components/Sidebar.logic.ts+51| ... | @@ -23,6 +23,57 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; | ... | @@ -23,6 +23,57 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; |
| 23 | // so this limit is a direct renderer-heap and server-load multiplier — keep | 23 | // so this limit is a direct renderer-heap and server-load multiplier — keep |
| 24 | // it small; cold opens still render instantly from the cached snapshot. | 24 | // it small; cold opens still render instantly from the cached snapshot. |
| 25 | export const SIDEBAR_THREAD_PREWARM_LIMIT = 3; | 25 | export const SIDEBAR_THREAD_PREWARM_LIMIT = 3; |
| 26 | // A small buffer keeps the next few rows warm without leasing every row that | ||
| 27 | // content-visibility leaves mounted below the scroll viewport. | ||
| 28 | export const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; | ||
| 29 | |||
| 30 | export function useSidebarRowSubscriptionLease(isActive: boolean): { | ||
| 31 | readonly leaseLiveStatus: boolean; | ||
| 32 | readonly rowRef: React.Dispatch<React.SetStateAction<HTMLElement | null>>; | ||
| 33 | } { | ||
| 34 | const [row, setRow] = React.useState<HTMLElement | null>(null); | ||
| 35 | const [isNearViewport, setIsNearViewport] = React.useState(isActive); | ||
| 36 | |||
| 37 | React.useEffect(() => { | ||
| 38 | if (isActive) { | ||
| 39 | setIsNearViewport(true); | ||
| 40 | return; | ||
| 41 | } | ||
| 42 | if (row === null) return; | ||
| 43 | if (typeof IntersectionObserver === "undefined") { | ||
| 44 | setIsNearViewport(true); | ||
| 45 | return; | ||
| 46 | } | ||
| 47 | |||
| 48 | const scrollRoot = row.closest<HTMLElement>('[data-slot="scroll-area-viewport"]'); | ||
| 49 | const observer = new IntersectionObserver( | ||
| 50 | ([entry]) => setIsNearViewport(entry?.isIntersecting === true), | ||
| 51 | { | ||
| 52 | root: scrollRoot, | ||
| 53 | rootMargin: `${SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX}px 0px`, | ||
| 54 | }, | ||
| 55 | ); | ||
| 56 | observer.observe(row); | ||
| 57 | return () => observer.disconnect(); | ||
| 58 | }, [isActive, row]); | ||
| 59 | |||
| 60 | return { | ||
| 61 | leaseLiveStatus: isActive || isNearViewport, | ||
| 62 | rowRef: setRow, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | // A row keeps the last live value it rendered so a released lease never | ||
| 67 | // blanks its badge. The value is bound to `key`, so a different worktree or | ||
| 68 | // linked pull request cannot reuse the previous one. | ||
| 69 | export function useRetainedValue<T>(key: string | null, value: T | null): T | null { | ||
| 70 | const retained = React.useRef<{ readonly key: string; readonly value: T } | null>(null); | ||
| 71 | if (key !== null && value !== null) { | ||
| 72 | retained.current = { key, value }; | ||
| 73 | } | ||
| 74 | if (value !== null) return value; | ||
| 75 | return key !== null && retained.current?.key === key ? retained.current.value : null; | ||
| 76 | } | ||
| 26 | 77 | ||
| 27 | // The list already reaches its destination through sortable transforms while | 78 | // The list already reaches its destination through sortable transforms while |
| 28 | // the pointer is down. dnd-kit's default also animates the committed DOM order | 79 | // the pointer is down. dnd-kit's default also animates the committed DOM order |
apps/web/src/components/Sidebar.tsx+27-10| ... | @@ -143,6 +143,8 @@ import { | ... | @@ -143,6 +143,8 @@ import { |
| 143 | sortPinnedThreadsForSidebar, | 143 | sortPinnedThreadsForSidebar, |
| 144 | sortSettledThreadsForSidebar, | 144 | sortSettledThreadsForSidebar, |
| 145 | sortThreadsForSidebar, | 145 | sortThreadsForSidebar, |
| 146 | useRetainedValue, | ||
| 147 | useSidebarRowSubscriptionLease, | ||
| 146 | useThreadJumpHintVisibility, | 148 | useThreadJumpHintVisibility, |
| 147 | } from "./Sidebar.logic"; | 149 | } from "./Sidebar.logic"; |
| 148 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; | 150 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| ... | @@ -787,6 +789,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -787,6 +789,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 787 | [thread.environmentId, thread.id], | 789 | [thread.environmentId, thread.id], |
| 788 | ); | 790 | ); |
| 789 | const threadKey = scopedThreadKey(threadRef); | 791 | const threadKey = scopedThreadKey(threadRef); |
| 792 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); | ||
| 790 | const isRegeneratingTitle = thread.titleRegeneration != null; | 793 | const isRegeneratingTitle = thread.titleRegeneration != null; |
| 791 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); | 794 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); |
| 792 | const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); | 795 | const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); |
| ... | @@ -800,21 +803,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -800,21 +803,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 800 | 803 | ||
| 801 | const gitCwd = thread.worktreePath ?? props.projectCwd; | 804 | const gitCwd = thread.worktreePath ?? props.projectCwd; |
| 802 | const linkedPullRequestStatus = useLinkedThreadPullRequest( | 805 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| 803 | thread.environmentId, | 806 | leaseLiveStatus ? thread.environmentId : null, |
| 804 | thread.linkedPullRequest, | 807 | leaseLiveStatus ? thread.linkedPullRequest : null, |
| 805 | ); | 808 | ); |
| 806 | const gitStatus = useEnvironmentQuery( | 809 | const gitStatus = useEnvironmentQuery( |
| 807 | (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null | 810 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null |
| 808 | ? vcsEnvironment.status({ | 811 | ? vcsEnvironment.status({ |
| 809 | environmentId: thread.environmentId, | 812 | environmentId: thread.environmentId, |
| 810 | input: { cwd: gitCwd }, | 813 | input: { cwd: gitCwd }, |
| 811 | }) | 814 | }) |
| 812 | : null, | 815 | : null, |
| 813 | ); | 816 | ); |
| 817 | const visibleGitStatus = useRetainedValue( | ||
| 818 | JSON.stringify([thread.environmentId, gitCwd]), | ||
| 819 | gitStatus.data, | ||
| 820 | ); | ||
| 814 | const retainTerminalOnBranchMismatch = thread.worktreePath === null; | 821 | const retainTerminalOnBranchMismatch = thread.worktreePath === null; |
| 815 | const pr = resolveDisplayedThreadPr({ | 822 | const pr = resolveDisplayedThreadPr({ |
| 816 | threadBranch: thread.branch, | 823 | threadBranch: thread.branch, |
| 817 | gitStatus: gitStatus.data, | 824 | gitStatus: visibleGitStatus, |
| 818 | snapshot: changeRequestSnapshot, | 825 | snapshot: changeRequestSnapshot, |
| 819 | retainTerminalOnBranchMismatch, | 826 | retainTerminalOnBranchMismatch, |
| 820 | linkedPullRequest: thread.linkedPullRequest, | 827 | linkedPullRequest: thread.linkedPullRequest, |
| ... | @@ -909,11 +916,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -909,11 +916,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 909 | effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", | 916 | effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", |
| 910 | activeWorktreePath: thread.worktreePath, | 917 | activeWorktreePath: thread.worktreePath, |
| 911 | activeThreadBranch: thread.branch, | 918 | activeThreadBranch: thread.branch, |
| 912 | currentGitBranch: gitStatus.data?.refName ?? null, | 919 | currentGitBranch: visibleGitStatus?.refName ?? null, |
| 913 | }); | 920 | }); |
| 914 | const prProvider = resolveDisplayedThreadPrProvider({ | 921 | const prProvider = resolveDisplayedThreadPrProvider({ |
| 915 | threadBranch: thread.branch, | 922 | threadBranch: thread.branch, |
| 916 | gitStatus: gitStatus.data, | 923 | gitStatus: visibleGitStatus, |
| 917 | snapshot: changeRequestSnapshot, | 924 | snapshot: changeRequestSnapshot, |
| 918 | retainTerminalOnBranchMismatch, | 925 | retainTerminalOnBranchMismatch, |
| 919 | linkedPullRequest: thread.linkedPullRequest, | 926 | linkedPullRequest: thread.linkedPullRequest, |
| ... | @@ -924,7 +931,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -924,7 +931,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 924 | useEffect(() => { | 931 | useEffect(() => { |
| 925 | const nextSnapshot = nextThreadChangeRequestSnapshot({ | 932 | const nextSnapshot = nextThreadChangeRequestSnapshot({ |
| 926 | threadBranch: thread.branch, | 933 | threadBranch: thread.branch, |
| 927 | gitStatus: gitStatus.data, | 934 | gitStatus: visibleGitStatus, |
| 928 | snapshot: changeRequestSnapshot, | 935 | snapshot: changeRequestSnapshot, |
| 929 | retainTerminalOnBranchMismatch, | 936 | retainTerminalOnBranchMismatch, |
| 930 | linkedPullRequest: thread.linkedPullRequest, | 937 | linkedPullRequest: thread.linkedPullRequest, |
| ... | @@ -934,7 +941,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -934,7 +941,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 934 | onChangeRequestSnapshot(threadKey, nextSnapshot); | 941 | onChangeRequestSnapshot(threadKey, nextSnapshot); |
| 935 | }, [ | 942 | }, [ |
| 936 | changeRequestSnapshot, | 943 | changeRequestSnapshot, |
| 937 | gitStatus.data, | 944 | visibleGitStatus, |
| 938 | linkedPullRequestStatus, | 945 | linkedPullRequestStatus, |
| 939 | onChangeRequestSnapshot, | 946 | onChangeRequestSnapshot, |
| 940 | retainTerminalOnBranchMismatch, | 947 | retainTerminalOnBranchMismatch, |
| ... | @@ -1246,6 +1253,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1246,6 +1253,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1246 | <TooltipTrigger | 1253 | <TooltipTrigger |
| 1247 | render={ | 1254 | render={ |
| 1248 | <div | 1255 | <div |
| 1256 | ref={rowRef} | ||
| 1249 | role="button" | 1257 | role="button" |
| 1250 | tabIndex={0} | 1258 | tabIndex={0} |
| 1251 | data-testid="sidebar-row-slim" | 1259 | data-testid="sidebar-row-slim" |
| ... | @@ -1407,6 +1415,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1407,6 +1415,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1407 | <TooltipTrigger | 1415 | <TooltipTrigger |
| 1408 | render={ | 1416 | render={ |
| 1409 | <div | 1417 | <div |
| 1418 | ref={rowRef} | ||
| 1410 | role="button" | 1419 | role="button" |
| 1411 | tabIndex={0} | 1420 | tabIndex={0} |
| 1412 | data-testid="sidebar-row-card" | 1421 | data-testid="sidebar-row-card" |
| ... | @@ -1635,22 +1644,29 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { | ... | @@ -1635,22 +1644,29 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1635 | onSelect: () => void; | 1644 | onSelect: () => void; |
| 1636 | }) { | 1645 | }) { |
| 1637 | const { thread } = props; | 1646 | const { thread } = props; |
| 1647 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( | ||
| 1648 | props.isHighlighted || props.isRouteActive, | ||
| 1649 | ); | ||
| 1638 | // Same details tooltip as the regular rows: a search hit is still a thread, | 1650 | // Same details tooltip as the regular rows: a search hit is still a thread, |
| 1639 | // and the hover card is how you disambiguate identically-titled results. | 1651 | // and the hover card is how you disambiguate identically-titled results. |
| 1640 | const gitCwd = thread.worktreePath ?? props.projectCwd; | 1652 | const gitCwd = thread.worktreePath ?? props.projectCwd; |
| 1641 | const gitStatus = useEnvironmentQuery( | 1653 | const gitStatus = useEnvironmentQuery( |
| 1642 | (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null | 1654 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null |
| 1643 | ? vcsEnvironment.status({ | 1655 | ? vcsEnvironment.status({ |
| 1644 | environmentId: thread.environmentId, | 1656 | environmentId: thread.environmentId, |
| 1645 | input: { cwd: gitCwd }, | 1657 | input: { cwd: gitCwd }, |
| 1646 | }) | 1658 | }) |
| 1647 | : null, | 1659 | : null, |
| 1648 | ); | 1660 | ); |
| 1661 | const visibleGitStatus = useRetainedValue( | ||
| 1662 | JSON.stringify([thread.environmentId, gitCwd]), | ||
| 1663 | gitStatus.data, | ||
| 1664 | ); | ||
| 1649 | const branchMismatch = resolveLocalCheckoutBranchMismatch({ | 1665 | const branchMismatch = resolveLocalCheckoutBranchMismatch({ |
| 1650 | effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", | 1666 | effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", |
| 1651 | activeWorktreePath: thread.worktreePath, | 1667 | activeWorktreePath: thread.worktreePath, |
| 1652 | activeThreadBranch: thread.branch, | 1668 | activeThreadBranch: thread.branch, |
| 1653 | currentGitBranch: gitStatus.data?.refName ?? null, | 1669 | currentGitBranch: visibleGitStatus?.refName ?? null, |
| 1654 | }); | 1670 | }); |
| 1655 | const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; | 1671 | const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; |
| 1656 | const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; | 1672 | const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; |
| ... | @@ -1674,6 +1690,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { | ... | @@ -1674,6 +1690,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1674 | <TooltipTrigger | 1690 | <TooltipTrigger |
| 1675 | render={ | 1691 | render={ |
| 1676 | <button | 1692 | <button |
| 1693 | ref={rowRef} | ||
| 1677 | id={props.resultId} | 1694 | id={props.resultId} |
| 1678 | type="button" | 1695 | type="button" |
| 1679 | role="option" | 1696 | role="option" |
packages/client-runtime/src/state/pullRequests.ts+3| ... | @@ -26,6 +26,8 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( | ... | @@ -26,6 +26,8 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( |
| 26 | "EnvironmentHttpConnectionNotReadyError", | 26 | "EnvironmentHttpConnectionNotReadyError", |
| 27 | )<{ readonly message: string }> {} | 27 | )<{ readonly message: string }> {} |
| 28 | 28 | ||
| 29 | export const LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS = 5_000; | ||
| 30 | |||
| 29 | /** Refresh a linked PR while its thread is visible so merges update the sidebar. */ | 31 | /** Refresh a linked PR while its thread is visible so merges update the sidebar. */ |
| 30 | export function createLinkedPullRequestDetailAtomFamily<R, E>( | 32 | export function createLinkedPullRequestDetailAtomFamily<R, E>( |
| 31 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | 33 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |
| ... | @@ -35,6 +37,7 @@ export function createLinkedPullRequestDetailAtomFamily<R, E>( | ... | @@ -35,6 +37,7 @@ export function createLinkedPullRequestDetailAtomFamily<R, E>( |
| 35 | tag: WS_METHODS.pullRequestsDetail, | 37 | tag: WS_METHODS.pullRequestsDetail, |
| 36 | staleTimeMs: 15_000, | 38 | staleTimeMs: 15_000, |
| 37 | refreshIntervalMs: 30_000, | 39 | refreshIntervalMs: 30_000, |
| 40 | idleTtlMs: LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS, | ||
| 38 | }); | 41 | }); |
| 39 | } | 42 | } |
| 40 | 43 |
packages/client-runtime/src/state/vcs.ts+4| ... | @@ -31,6 +31,9 @@ import { | ... | @@ -31,6 +31,9 @@ import { |
| 31 | 31 | ||
| 32 | const OFFLINE_BRANCH_LIST_LIMIT = 100; | 32 | const OFFLINE_BRANCH_LIST_LIMIT = 100; |
| 33 | const VCS_REFS_IDLE_TTL_MS = 30_000; | 33 | const VCS_REFS_IDLE_TTL_MS = 30_000; |
| 34 | // Rows keep the last status they rendered, so the live stream only needs a | ||
| 35 | // short grace period when virtualization or scrolling releases its consumer. | ||
| 36 | export const VCS_STATUS_IDLE_TTL_MS = 10_000; | ||
| 34 | const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( | 37 | const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( |
| 35 | Schedule.modifyDelay(({ duration }) => | 38 | Schedule.modifyDelay(({ duration }) => |
| 36 | Effect.succeed(Duration.min(duration, Duration.seconds(30))), | 39 | Effect.succeed(Duration.min(duration, Duration.seconds(30))), |
| ... | @@ -275,6 +278,7 @@ export function createVcsEnvironmentAtoms<R, E>( | ... | @@ -275,6 +278,7 @@ export function createVcsEnvironmentAtoms<R, E>( |
| 275 | listRefs, | 278 | listRefs, |
| 276 | status: createEnvironmentSubscriptionAtomFamily(runtime, { | 279 | status: createEnvironmentSubscriptionAtomFamily(runtime, { |
| 277 | label: "environment-data:vcs:status", | 280 | label: "environment-data:vcs:status", |
| 281 | idleTtlMs: VCS_STATUS_IDLE_TTL_MS, | ||
| 278 | subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.subscribeVcsStatus>) => | 282 | subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.subscribeVcsStatus>) => |
| 279 | subscribe(WS_METHODS.subscribeVcsStatus, input).pipe( | 283 | subscribe(WS_METHODS.subscribeVcsStatus, input).pipe( |
| 280 | Stream.mapAccum( | 284 | Stream.mapAccum( |