authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-09-02 03:13:04+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 18:13:04-07:00
logfc53b273039cf5e0a932582b1fbb56e7223ad4ab
tree93f3232e11f2e9f422523efbb820cb88bfdd2d91
parent6866fd6b5ccff82333ad4386ce68cdec03531b68
signature Signed by PGP key B5690EEEBB952194

perf(clients): lease sidebar status by visibility (#9052)

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 @@
1import { useAtomValue } from "@effect/atom-react";
2import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import {4import {
3 createLinkedPullRequestDetailAtomFamily,5 createLinkedPullRequestDetailAtomFamily,
4 pullRequestDetailToVcsStatus,6 pullRequestDetailToVcsStatus,
5} from "@t3tools/client-runtime/state/pull-requests";7} from "@t3tools/client-runtime/state/pull-requests";
8import { Atom } from "effect/unstable/reactivity";
9import { useCallback, useEffect, useMemo } from "react";
610
7import { connectionAtomRuntime } from "../connection/runtime";11import { connectionAtomRuntime } from "../connection/runtime";
12import { appAtomRegistry } from "./atom-registry";
8import { useEnvironmentQuery } from "./query";13import { useEnvironmentQuery } from "./query";
9import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";14import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
10import { vcsEnvironment } from "./vcs";15import { vcsEnvironment } from "./vcs";
1116
12const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime);17const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime);
18const MAX_THREAD_PR_SNAPSHOTS = 500;
19
20interface 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.
27const threadPrSnapshotsAtom = Atom.make<ReadonlyMap<string, ThreadPrSnapshot>>(new Map()).pipe(
28 Atom.keepAlive,
29 Atom.withLabel("mobile:thread-pr-snapshots"),
30);
1331
14export {32export {
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 !== null63 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 );
5182
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 === null85 const detail = linkedPullRequest.data;
55 ? null86 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]);
62126
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 !== null423 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 == null479 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
681695
682 return (696 return (
683 <SidebarMenuSubItem697 <SidebarMenuSubItem
698 ref={rowRef}
684 className="w-full"699 className="w-full"
685 data-thread-item700 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 — keep23// 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.
25export const SIDEBAR_THREAD_PREWARM_LIMIT = 3;25export 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.
28export const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160;
29
30export 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.
69export 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}
2677
27// The list already reaches its destination through sortable transforms while78// The list already reaches its destination through sortable transforms while
28// the pointer is down. dnd-kit's default also animates the committed DOM order79// 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";
148import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";150import { 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: {
800803
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 !== null810 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 <TooltipTrigger1253 <TooltipTrigger
1247 render={1254 render={
1248 <div1255 <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 <TooltipTrigger1415 <TooltipTrigger
1408 render={1416 render={
1409 <div1417 <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 !== null1654 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 <TooltipTrigger1690 <TooltipTrigger
1675 render={1691 render={
1676 <button1692 <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 }> {}
2828
29export 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. */
30export function createLinkedPullRequestDetailAtomFamily<R, E>(32export 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}
4043
packages/client-runtime/src/state/vcs.ts+4
...@@ -31,6 +31,9 @@ import {...@@ -31,6 +31,9 @@ import {
3131
32const OFFLINE_BRANCH_LIST_LIMIT = 100;32const OFFLINE_BRANCH_LIST_LIMIT = 100;
33const VCS_REFS_IDLE_TTL_MS = 30_000;33const 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.
36export const VCS_STATUS_IDLE_TTL_MS = 10_000;
34const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe(37const 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(