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 @@
1import { useAtomValue } from "@effect/atom-react";
2import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
13import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
24import {
35 createLinkedPullRequestDetailAtomFamily,
46 pullRequestDetailToVcsStatus,
57} from "@t3tools/client-runtime/state/pull-requests";
8import { Atom } from "effect/unstable/reactivity";
9import { useCallback, useEffect, useMemo } from "react";
610
711import { connectionAtomRuntime } from "../connection/runtime";
12import { appAtomRegistry } from "./atom-registry";
813import { useEnvironmentQuery } from "./query";
914import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
1015import { vcsEnvironment } from "./vcs";
1116
1217const 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
1432export {
1533 presentThreadPr,
......@@ -28,6 +46,19 @@ export function useThreadPr(
2846 projectCwd: string | null,
2947): ThreadPrPresentation | null {
3048 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;
3162 const gitStatus = useEnvironmentQuery(
3263 thread.linkedPullRequest == null && thread.branch !== null && cwd !== null
3364 ? vcsEnvironment.status({
......@@ -49,23 +80,49 @@ export function useThreadPr(
4980 }),
5081 );
5182
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]);
62126
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;
71128}
apps/web/src/components/LegacySidebar.tsx+21-6
......@@ -185,6 +185,8 @@ import {
185185 orderItemsByPreferredIds,
186186 shouldClearThreadSelectionOnMouseDown,
187187 sortProjectsForSidebar,
188 useRetainedValue,
189 useSidebarRowSubscriptionLease,
188190 useThreadJumpHintVisibility,
189191 ThreadStatusPill,
190192} from "./Sidebar.logic";
......@@ -376,6 +378,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
376378 } = props;
377379 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
378380 const threadKey = scopedThreadKey(threadRef);
381 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive);
379382 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
380383 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
381384 const runningTerminalIds = useThreadRunningTerminalIds({
......@@ -417,7 +420,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
417420 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
418421 const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;
419422 const gitStatus = useEnvironmentQuery(
420 thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null
423 leaseLiveStatus && thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null
421424 ? vcsEnvironment.status({
422425 environmentId: thread.environmentId,
423426 input: { cwd: gitCwd },
......@@ -459,16 +462,27 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
459462 },
460463 });
461464 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,
464477 );
465478 const pr =
466479 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);
469482 const prStatus = prStatusIndicator(
470483 pr,
471 linkedPullRequestStatus?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider,
484 visibleLinkedPullRequestStatus?.sourceControlProvider ??
485 visibleGitStatus?.sourceControlProvider,
472486 );
473487 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
474488 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
......@@ -681,6 +695,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
681695
682696 return (
683697 <SidebarMenuSubItem
698 ref={rowRef}
684699 className="w-full"
685700 data-thread-item
686701 onMouseLeave={handleMouseLeave}
apps/web/src/components/Sidebar.logic.ts+51
......@@ -23,6 +23,57 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200;
2323// so this limit is a direct renderer-heap and server-load multiplier — keep
2424// it small; cold opens still render instantly from the cached snapshot.
2525export 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
2778// The list already reaches its destination through sortable transforms while
2879// 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 {
143143 sortPinnedThreadsForSidebar,
144144 sortSettledThreadsForSidebar,
145145 sortThreadsForSidebar,
146 useRetainedValue,
147 useSidebarRowSubscriptionLease,
146148 useThreadJumpHintVisibility,
147149} from "./Sidebar.logic";
148150import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
......@@ -787,6 +789,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
787789 [thread.environmentId, thread.id],
788790 );
789791 const threadKey = scopedThreadKey(threadRef);
792 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive);
790793 const isRegeneratingTitle = thread.titleRegeneration != null;
791794 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
792795 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
......@@ -800,21 +803,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
800803
801804 const gitCwd = thread.worktreePath ?? props.projectCwd;
802805 const linkedPullRequestStatus = useLinkedThreadPullRequest(
803 thread.environmentId,
804 thread.linkedPullRequest,
806 leaseLiveStatus ? thread.environmentId : null,
807 leaseLiveStatus ? thread.linkedPullRequest : null,
805808 );
806809 const gitStatus = useEnvironmentQuery(
807 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
810 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
808811 ? vcsEnvironment.status({
809812 environmentId: thread.environmentId,
810813 input: { cwd: gitCwd },
811814 })
812815 : null,
813816 );
817 const visibleGitStatus = useRetainedValue(
818 JSON.stringify([thread.environmentId, gitCwd]),
819 gitStatus.data,
820 );
814821 const retainTerminalOnBranchMismatch = thread.worktreePath === null;
815822 const pr = resolveDisplayedThreadPr({
816823 threadBranch: thread.branch,
817 gitStatus: gitStatus.data,
824 gitStatus: visibleGitStatus,
818825 snapshot: changeRequestSnapshot,
819826 retainTerminalOnBranchMismatch,
820827 linkedPullRequest: thread.linkedPullRequest,
......@@ -909,11 +916,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
909916 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
910917 activeWorktreePath: thread.worktreePath,
911918 activeThreadBranch: thread.branch,
912 currentGitBranch: gitStatus.data?.refName ?? null,
919 currentGitBranch: visibleGitStatus?.refName ?? null,
913920 });
914921 const prProvider = resolveDisplayedThreadPrProvider({
915922 threadBranch: thread.branch,
916 gitStatus: gitStatus.data,
923 gitStatus: visibleGitStatus,
917924 snapshot: changeRequestSnapshot,
918925 retainTerminalOnBranchMismatch,
919926 linkedPullRequest: thread.linkedPullRequest,
......@@ -924,7 +931,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
924931 useEffect(() => {
925932 const nextSnapshot = nextThreadChangeRequestSnapshot({
926933 threadBranch: thread.branch,
927 gitStatus: gitStatus.data,
934 gitStatus: visibleGitStatus,
928935 snapshot: changeRequestSnapshot,
929936 retainTerminalOnBranchMismatch,
930937 linkedPullRequest: thread.linkedPullRequest,
......@@ -934,7 +941,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
934941 onChangeRequestSnapshot(threadKey, nextSnapshot);
935942 }, [
936943 changeRequestSnapshot,
937 gitStatus.data,
944 visibleGitStatus,
938945 linkedPullRequestStatus,
939946 onChangeRequestSnapshot,
940947 retainTerminalOnBranchMismatch,
......@@ -1246,6 +1253,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12461253 <TooltipTrigger
12471254 render={
12481255 <div
1256 ref={rowRef}
12491257 role="button"
12501258 tabIndex={0}
12511259 data-testid="sidebar-row-slim"
......@@ -1407,6 +1415,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14071415 <TooltipTrigger
14081416 render={
14091417 <div
1418 ref={rowRef}
14101419 role="button"
14111420 tabIndex={0}
14121421 data-testid="sidebar-row-card"
......@@ -1635,22 +1644,29 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
16351644 onSelect: () => void;
16361645}) {
16371646 const { thread } = props;
1647 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(
1648 props.isHighlighted || props.isRouteActive,
1649 );
16381650 // Same details tooltip as the regular rows: a search hit is still a thread,
16391651 // and the hover card is how you disambiguate identically-titled results.
16401652 const gitCwd = thread.worktreePath ?? props.projectCwd;
16411653 const gitStatus = useEnvironmentQuery(
1642 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
1654 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
16431655 ? vcsEnvironment.status({
16441656 environmentId: thread.environmentId,
16451657 input: { cwd: gitCwd },
16461658 })
16471659 : null,
16481660 );
1661 const visibleGitStatus = useRetainedValue(
1662 JSON.stringify([thread.environmentId, gitCwd]),
1663 gitStatus.data,
1664 );
16491665 const branchMismatch = resolveLocalCheckoutBranchMismatch({
16501666 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
16511667 activeWorktreePath: thread.worktreePath,
16521668 activeThreadBranch: thread.branch,
1653 currentGitBranch: gitStatus.data?.refName ?? null,
1669 currentGitBranch: visibleGitStatus?.refName ?? null,
16541670 });
16551671 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
16561672 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
......@@ -1674,6 +1690,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
16741690 <TooltipTrigger
16751691 render={
16761692 <button
1693 ref={rowRef}
16771694 id={props.resultId}
16781695 type="button"
16791696 role="option"
packages/client-runtime/src/state/pullRequests.ts+3
......@@ -26,6 +26,8 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(
2626 "EnvironmentHttpConnectionNotReadyError",
2727)<{ readonly message: string }> {}
2828
29export const LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS = 5_000;
30
2931/** Refresh a linked PR while its thread is visible so merges update the sidebar. */
3032export function createLinkedPullRequestDetailAtomFamily<R, E>(
3133 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
......@@ -35,6 +37,7 @@ export function createLinkedPullRequestDetailAtomFamily<R, E>(
3537 tag: WS_METHODS.pullRequestsDetail,
3638 staleTimeMs: 15_000,
3739 refreshIntervalMs: 30_000,
40 idleTtlMs: LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS,
3841 });
3942}
4043
packages/client-runtime/src/state/vcs.ts+4
......@@ -31,6 +31,9 @@ import {
3131
3232const OFFLINE_BRANCH_LIST_LIMIT = 100;
3333const 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;
3437const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe(
3538 Schedule.modifyDelay(({ duration }) =>
3639 Effect.succeed(Duration.min(duration, Duration.seconds(30))),
......@@ -275,6 +278,7 @@ export function createVcsEnvironmentAtoms<R, E>(
275278 listRefs,
276279 status: createEnvironmentSubscriptionAtomFamily(runtime, {
277280 label: "environment-data:vcs:status",
281 idleTtlMs: VCS_STATUS_IDLE_TTL_MS,
278282 subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.subscribeVcsStatus>) =>
279283 subscribe(WS_METHODS.subscribeVcsStatus, input).pipe(
280284 Stream.mapAccum(