| 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 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 1 | 3 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | 4 | import { |
| 3 | 5 | createLinkedPullRequestDetailAtomFamily, |
| 4 | 6 | pullRequestDetailToVcsStatus, |
| 5 | 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 | 11 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 12 | import { appAtomRegistry } from "./atom-registry"; | |
| 8 | 13 | import { useEnvironmentQuery } from "./query"; |
| 9 | 14 | import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; |
| 10 | 15 | import { vcsEnvironment } from "./vcs"; |
| 11 | 16 | |
| 12 | 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 | 32 | export { |
| 15 | 33 | presentThreadPr, |
| ... | ... | @@ -28,6 +46,19 @@ export function useThreadPr( |
| 28 | 46 | projectCwd: string | null, |
| 29 | 47 | ): ThreadPrPresentation | null { |
| 30 | 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 | 62 | const gitStatus = useEnvironmentQuery( |
| 32 | 63 | thread.linkedPullRequest == null && thread.branch !== null && cwd !== null |
| 33 | 64 | ? vcsEnvironment.status({ |
| ... | ... | @@ -49,23 +80,49 @@ export function useThreadPr( |
| 49 | 80 | }), |
| 50 | 81 | ); |
| 51 | 82 | |
| 52 | if (thread.linkedPullRequest != null) { | |
| 53 | const detail = linkedPullRequest.data; | |
| 54 | return detail === null | |
| 55 | ? null | |
| 56 | : presentThreadPr(pullRequestDetailToVcsStatus(detail), { | |
| 57 | kind: detail.provider, | |
| 58 | name: detail.provider, | |
| 59 | baseUrl: "", | |
| 60 | }); | |
| 61 | } | |
| 83 | const live = useMemo<ThreadPrPresentation | null | undefined>(() => { | |
| 84 | if (thread.linkedPullRequest != null) { | |
| 85 | const detail = linkedPullRequest.data; | |
| 86 | return detail === null | |
| 87 | ? undefined | |
| 88 | : presentThreadPr(pullRequestDetailToVcsStatus(detail), { | |
| 89 | kind: detail.provider, | |
| 90 | name: detail.provider, | |
| 91 | baseUrl: "", | |
| 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; | |
| 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); | |
| 127 | return live === undefined ? snapshot : live; | |
| 71 | 128 | } |
apps/web/src/components/LegacySidebar.tsx+21-6| ... | ... | @@ -185,6 +185,8 @@ import { |
| 185 | 185 | orderItemsByPreferredIds, |
| 186 | 186 | shouldClearThreadSelectionOnMouseDown, |
| 187 | 187 | sortProjectsForSidebar, |
| 188 | useRetainedValue, | |
| 189 | useSidebarRowSubscriptionLease, | |
| 188 | 190 | useThreadJumpHintVisibility, |
| 189 | 191 | ThreadStatusPill, |
| 190 | 192 | } from "./Sidebar.logic"; |
| ... | ... | @@ -376,6 +378,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 376 | 378 | } = props; |
| 377 | 379 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); |
| 378 | 380 | const threadKey = scopedThreadKey(threadRef); |
| 381 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); | |
| 379 | 382 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); |
| 380 | 383 | const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); |
| 381 | 384 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| ... | ... | @@ -417,7 +420,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 417 | 420 | const threadProjectCwd = threadProject?.workspaceRoot ?? null; |
| 418 | 421 | const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; |
| 419 | 422 | const gitStatus = useEnvironmentQuery( |
| 420 | thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null | |
| 423 | leaseLiveStatus && thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null | |
| 421 | 424 | ? vcsEnvironment.status({ |
| 422 | 425 | environmentId: thread.environmentId, |
| 423 | 426 | input: { cwd: gitCwd }, |
| ... | ... | @@ -459,16 +462,27 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 459 | 462 | }, |
| 460 | 463 | }); |
| 461 | 464 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| 462 | thread.environmentId, | |
| 463 | thread.linkedPullRequest, | |
| 465 | leaseLiveStatus ? thread.environmentId : null, | |
| 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 | 478 | const pr = |
| 466 | 479 | thread.linkedPullRequest == null |
| 467 | ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) | |
| 468 | : (linkedPullRequestStatus?.pr ?? null); | |
| 480 | ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: visibleGitStatus }) | |
| 481 | : (visibleLinkedPullRequestStatus?.pr ?? null); | |
| 469 | 482 | const prStatus = prStatusIndicator( |
| 470 | 483 | pr, |
| 471 | linkedPullRequestStatus?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, | |
| 484 | visibleLinkedPullRequestStatus?.sourceControlProvider ?? | |
| 485 | visibleGitStatus?.sourceControlProvider, | |
| 472 | 486 | ); |
| 473 | 487 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 474 | 488 | const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; |
| ... | ... | @@ -681,6 +695,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 681 | 695 | |
| 682 | 696 | return ( |
| 683 | 697 | <SidebarMenuSubItem |
| 698 | ref={rowRef} | |
| 684 | 699 | className="w-full" |
| 685 | 700 | data-thread-item |
| 686 | 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 | 23 | // so this limit is a direct renderer-heap and server-load multiplier — keep |
| 24 | 24 | // it small; cold opens still render instantly from the cached snapshot. |
| 25 | 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 | 78 | // The list already reaches its destination through sortable transforms while |
| 28 | 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 | 143 | sortPinnedThreadsForSidebar, |
| 144 | 144 | sortSettledThreadsForSidebar, |
| 145 | 145 | sortThreadsForSidebar, |
| 146 | useRetainedValue, | |
| 147 | useSidebarRowSubscriptionLease, | |
| 146 | 148 | useThreadJumpHintVisibility, |
| 147 | 149 | } from "./Sidebar.logic"; |
| 148 | 150 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| ... | ... | @@ -787,6 +789,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 787 | 789 | [thread.environmentId, thread.id], |
| 788 | 790 | ); |
| 789 | 791 | const threadKey = scopedThreadKey(threadRef); |
| 792 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); | |
| 790 | 793 | const isRegeneratingTitle = thread.titleRegeneration != null; |
| 791 | 794 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); |
| 792 | 795 | const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); |
| ... | ... | @@ -800,21 +803,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 800 | 803 | |
| 801 | 804 | const gitCwd = thread.worktreePath ?? props.projectCwd; |
| 802 | 805 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| 803 | thread.environmentId, | |
| 804 | thread.linkedPullRequest, | |
| 806 | leaseLiveStatus ? thread.environmentId : null, | |
| 807 | leaseLiveStatus ? thread.linkedPullRequest : null, | |
| 805 | 808 | ); |
| 806 | 809 | const gitStatus = useEnvironmentQuery( |
| 807 | (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null | |
| 810 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null | |
| 808 | 811 | ? vcsEnvironment.status({ |
| 809 | 812 | environmentId: thread.environmentId, |
| 810 | 813 | input: { cwd: gitCwd }, |
| 811 | 814 | }) |
| 812 | 815 | : null, |
| 813 | 816 | ); |
| 817 | const visibleGitStatus = useRetainedValue( | |
| 818 | JSON.stringify([thread.environmentId, gitCwd]), | |
| 819 | gitStatus.data, | |
| 820 | ); | |
| 814 | 821 | const retainTerminalOnBranchMismatch = thread.worktreePath === null; |
| 815 | 822 | const pr = resolveDisplayedThreadPr({ |
| 816 | 823 | threadBranch: thread.branch, |
| 817 | gitStatus: gitStatus.data, | |
| 824 | gitStatus: visibleGitStatus, | |
| 818 | 825 | snapshot: changeRequestSnapshot, |
| 819 | 826 | retainTerminalOnBranchMismatch, |
| 820 | 827 | linkedPullRequest: thread.linkedPullRequest, |
| ... | ... | @@ -909,11 +916,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 909 | 916 | effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", |
| 910 | 917 | activeWorktreePath: thread.worktreePath, |
| 911 | 918 | activeThreadBranch: thread.branch, |
| 912 | currentGitBranch: gitStatus.data?.refName ?? null, | |
| 919 | currentGitBranch: visibleGitStatus?.refName ?? null, | |
| 913 | 920 | }); |
| 914 | 921 | const prProvider = resolveDisplayedThreadPrProvider({ |
| 915 | 922 | threadBranch: thread.branch, |
| 916 | gitStatus: gitStatus.data, | |
| 923 | gitStatus: visibleGitStatus, | |
| 917 | 924 | snapshot: changeRequestSnapshot, |
| 918 | 925 | retainTerminalOnBranchMismatch, |
| 919 | 926 | linkedPullRequest: thread.linkedPullRequest, |
| ... | ... | @@ -924,7 +931,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 924 | 931 | useEffect(() => { |
| 925 | 932 | const nextSnapshot = nextThreadChangeRequestSnapshot({ |
| 926 | 933 | threadBranch: thread.branch, |
| 927 | gitStatus: gitStatus.data, | |
| 934 | gitStatus: visibleGitStatus, | |
| 928 | 935 | snapshot: changeRequestSnapshot, |
| 929 | 936 | retainTerminalOnBranchMismatch, |
| 930 | 937 | linkedPullRequest: thread.linkedPullRequest, |
| ... | ... | @@ -934,7 +941,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 934 | 941 | onChangeRequestSnapshot(threadKey, nextSnapshot); |
| 935 | 942 | }, [ |
| 936 | 943 | changeRequestSnapshot, |
| 937 | gitStatus.data, | |
| 944 | visibleGitStatus, | |
| 938 | 945 | linkedPullRequestStatus, |
| 939 | 946 | onChangeRequestSnapshot, |
| 940 | 947 | retainTerminalOnBranchMismatch, |
| ... | ... | @@ -1246,6 +1253,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1246 | 1253 | <TooltipTrigger |
| 1247 | 1254 | render={ |
| 1248 | 1255 | <div |
| 1256 | ref={rowRef} | |
| 1249 | 1257 | role="button" |
| 1250 | 1258 | tabIndex={0} |
| 1251 | 1259 | data-testid="sidebar-row-slim" |
| ... | ... | @@ -1407,6 +1415,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1407 | 1415 | <TooltipTrigger |
| 1408 | 1416 | render={ |
| 1409 | 1417 | <div |
| 1418 | ref={rowRef} | |
| 1410 | 1419 | role="button" |
| 1411 | 1420 | tabIndex={0} |
| 1412 | 1421 | data-testid="sidebar-row-card" |
| ... | ... | @@ -1635,22 +1644,29 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1635 | 1644 | onSelect: () => void; |
| 1636 | 1645 | }) { |
| 1637 | 1646 | const { thread } = props; |
| 1647 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( | |
| 1648 | props.isHighlighted || props.isRouteActive, | |
| 1649 | ); | |
| 1638 | 1650 | // Same details tooltip as the regular rows: a search hit is still a thread, |
| 1639 | 1651 | // and the hover card is how you disambiguate identically-titled results. |
| 1640 | 1652 | const gitCwd = thread.worktreePath ?? props.projectCwd; |
| 1641 | 1653 | const gitStatus = useEnvironmentQuery( |
| 1642 | (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null | |
| 1654 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null | |
| 1643 | 1655 | ? vcsEnvironment.status({ |
| 1644 | 1656 | environmentId: thread.environmentId, |
| 1645 | 1657 | input: { cwd: gitCwd }, |
| 1646 | 1658 | }) |
| 1647 | 1659 | : null, |
| 1648 | 1660 | ); |
| 1661 | const visibleGitStatus = useRetainedValue( | |
| 1662 | JSON.stringify([thread.environmentId, gitCwd]), | |
| 1663 | gitStatus.data, | |
| 1664 | ); | |
| 1649 | 1665 | const branchMismatch = resolveLocalCheckoutBranchMismatch({ |
| 1650 | 1666 | effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", |
| 1651 | 1667 | activeWorktreePath: thread.worktreePath, |
| 1652 | 1668 | activeThreadBranch: thread.branch, |
| 1653 | currentGitBranch: gitStatus.data?.refName ?? null, | |
| 1669 | currentGitBranch: visibleGitStatus?.refName ?? null, | |
| 1654 | 1670 | }); |
| 1655 | 1671 | const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; |
| 1656 | 1672 | const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; |
| ... | ... | @@ -1674,6 +1690,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1674 | 1690 | <TooltipTrigger |
| 1675 | 1691 | render={ |
| 1676 | 1692 | <button |
| 1693 | ref={rowRef} | |
| 1677 | 1694 | id={props.resultId} |
| 1678 | 1695 | type="button" |
| 1679 | 1696 | role="option" |
packages/client-runtime/src/state/pullRequests.ts+3| ... | ... | @@ -26,6 +26,8 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( |
| 26 | 26 | "EnvironmentHttpConnectionNotReadyError", |
| 27 | 27 | )<{ readonly message: string }> {} |
| 28 | 28 | |
| 29 | export const LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS = 5_000; | |
| 30 | ||
| 29 | 31 | /** Refresh a linked PR while its thread is visible so merges update the sidebar. */ |
| 30 | 32 | export function createLinkedPullRequestDetailAtomFamily<R, E>( |
| 31 | 33 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |
| ... | ... | @@ -35,6 +37,7 @@ export function createLinkedPullRequestDetailAtomFamily<R, E>( |
| 35 | 37 | tag: WS_METHODS.pullRequestsDetail, |
| 36 | 38 | staleTimeMs: 15_000, |
| 37 | 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 | 31 | |
| 32 | 32 | const OFFLINE_BRANCH_LIST_LIMIT = 100; |
| 33 | 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 | 37 | const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( |
| 35 | 38 | Schedule.modifyDelay(({ duration }) => |
| 36 | 39 | Effect.succeed(Duration.min(duration, Duration.seconds(30))), |
| ... | ... | @@ -275,6 +278,7 @@ export function createVcsEnvironmentAtoms<R, E>( |
| 275 | 278 | listRefs, |
| 276 | 279 | status: createEnvironmentSubscriptionAtomFamily(runtime, { |
| 277 | 280 | label: "environment-data:vcs:status", |
| 281 | idleTtlMs: VCS_STATUS_IDLE_TTL_MS, | |
| 278 | 282 | subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.subscribeVcsStatus>) => |
| 279 | 283 | subscribe(WS_METHODS.subscribeVcsStatus, input).pipe( |
| 280 | 284 | Stream.mapAccum( |