1import {
2 MERGEBOT_BROWSER_THREAD_ID,
3 ThreadId,
4 mergebotPullRequestUrl,
5 mergebotPullRequestBrowserThreadId,
6} from "@t3tools/contracts";
7import { scopeThreadRef } from "@t3tools/client-runtime/environment";
8import { MergebotBrowserPanel } from "../components/pullRequest/MergebotBrowserPanel";
9import { clearThreadPreviewState } from "../previewStateStore";
10import { previewEnvironment } from "../state/preview";
11import { useEnvironment } from "../state/environments";
12import { RefreshIcon } from "~/components/ui/refresh-icon";
13import { Spinner } from "~/components/ui/spinner";
14import { useShortcutModifierState } from "~/shortcutModifierState";
15import type { PullRequestSpeedActionResult } from "~/components/pullRequest/PullRequestSpeedActions";
16import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullRequestActions";
17import { SidebarPointerSensor } from "~/components/Sidebar.pointer";
18import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic";
19import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping";
20import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts";
21import type {
22 EnvironmentId,
23 ProjectId,
24 PullRequestAction,
25 PullRequestInvolvement,
26 PullRequestListCursors,
27 PullRequestListFilters,
28 PullRequestListInput,
29 PullRequestListResult,
30 PullRequestListState,
31 SourceControlProviderKind,
32} from "@t3tools/contracts";
33import { useAtomValue } from "@effect/atom-react";
34import { createFileRoute, useNavigate } from "@tanstack/react-router";
35import {
36 ArrowDownUpIcon,
37 CalendarArrowDownIcon,
38 CalendarArrowUpIcon,
39 ChevronDownIcon,
40 ClockIcon,
41 EyeIcon,
42 LayersIcon,
43 ListChecksIcon,
44 PenLineIcon,
45 UsersIcon,
46 Plug2Icon,
47 Maximize2Icon,
48 Minimize2Icon,
49 SearchIcon,
50 UserLockIcon,
51 type LucideIcon,
52} from "lucide-react";
53import {
54 useCallback,
55 useEffect,
56 useEffectEvent,
57 useMemo,
58 useRef,
59 useState,
60 type ComponentProps,
61 type ReactNode,
62 type RefObject,
63} from "react";
64
65import {
66 filterPullRequestsByInvolvement,
67 findScopedProject,
68 collectPullRequestListFacets,
69 groupPullRequestsByInvolvement,
70 matchesPullRequestFilters,
71 matchesPullRequestQuery,
72 parsePullRequestQuery,
73 narrowPullRequestsToFilters,
74 mergePullRequestDiffStats,
75 partitionPullRequestsWithPriority,
76 pullRequestDiffStatKey,
77 pullRequestEntryKey,
78 pullRequestEntryViewer,
79 rankPullRequestMatches,
80 sortPullRequestGroups,
81 pullRequestEnvironmentSetKey,
82 readPullRequestListSnapshot,
83 resolveProjectScope,
84 resolveQueryEnvironmentIds,
85 resolveSelectedEnvironmentId,
86 withDiffStat,
87 writePullRequestListSnapshot,
88 scorePullRequestMatch,
89 pullRequestStatsRefreshBatches,
90 pullRequestStatsRequestBatches,
91 retainVisiblePullRequestStatsBatches,
92 type EnvironmentPullRequestEntry,
93 type MergedPullRequestList,
94 type PullRequestDiffStats,
95 type PullRequestStatsBatch,
96 type PullRequestStatsPolicy,
97 type PullRequestStatsScope,
98 type PullRequestPartitionsSnapshot,
99 applyPullRequestOverrides,
100 type PullRequestListOverride,
101 pullRequestOverrideAfterAction,
102 reusePullRequestEntries,
103 settlePullRequestOverrides,
104 pullRequestHostActions,
105} from "../components/pullRequest/pullRequestList.logic";
106import {
107 pullRequestListPreferences,
108 type PullRequestListPreferencePatch,
109 type PullRequestListPreferences,
110 type PullRequestListSort,
111 writePullRequestListPreferences,
112} from "../components/pullRequest/pullRequestListPreferences";
113import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";
114import { pullRequestFilterProjects } from "../components/pullRequest/pullRequestProjectFilter.logic";
115import { environmentMachineIcon } from "../components/EnvironmentMachineIcon";
116import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
117import {
118 PullRequestFiltersMenu,
119 PullRequestFilterOptionIcon,
120 PullRequestSearchInput,
121 pullRequestHostLabel,
122 pullRequestProjectKey,
123 type PullRequestExpectedHost,
124 type PullRequestFilterOption,
125} from "../components/pullRequest/PullRequestListFilters";
126import { PullRequestListEmptyState } from "../components/pullRequest/PullRequestListEmptyState";
127import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhosts";
128import {
129 PullRequestRow,
130 type PullRequestRowTarget,
131} from "../components/pullRequest/PullRequestRow";
132import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState";
133import { RightPanelTabs, type PullRequestTabStatusSeed } from "../components/RightPanelTabs";
134import {
135 WorkspaceBreadcrumb,
136 WorkspaceBreadcrumbItem,
137 WorkspaceBreadcrumbSeparator,
138} from "../components/WorkspaceBreadcrumb";
139import { WorkspacePageContainer } from "../components/WorkspacePageContainer";
140import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
141import { isCommandPaletteOpen } from "../commandPaletteBus";
142import { isElectron } from "../env";
143import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
144import { isTerminalFocused } from "../lib/terminalFocus";
145import {
146 PanelLayoutControls,
147 RightPanelMaximizeControl,
148} from "../components/chat/PanelLayoutControls";
149import { Button } from "../components/ui/button";
150import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";
151import { SidebarInset } from "../components/ui/sidebar";
152import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
153import { useLiveRefresh } from "../hooks/useLiveRefresh";
154import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
155import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
156import { toastManager } from "../components/ui/toast";
157import { useEscapeToGoBack } from "../hooks/useNavigateBack";
158import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
159import {
160 PULL_REQUESTS_PANEL_REF,
161 pullRequestSurfaceId,
162 selectActiveRightPanelSurface,
163 selectSelectedRightPanelSurface,
164 selectThreadRightPanelState,
165 useRightPanelStore,
166 type PullRequestSurface,
167 type RightPanelSurface,
168} from "../rightPanelStore";
169import { useDebouncedValue } from "../state/queries";
170import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities";
171import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups";
172import { useEnvironments } from "../state/environments";
173import {
174 pullRequestEnvironment,
175 usePullRequestList,
176 usePullRequestListStats,
177 usePullRequestTurnRefreshes,
178 type EnvironmentQueryTarget,
179} from "../state/pullRequests";
180import { useAtomCommand } from "../state/use-atom-command";
181import { cn } from "~/lib/utils";
182import { Separator } from "~/components/ui/separator";
183import { primaryServerKeybindingsAtom } from "~/state/server";
184import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
185import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
186
187function getShortcutContext() {
188 return {
189 terminalFocus: isTerminalFocused(),
190 terminalOpen: false,
191 previewFocus: false,
192 previewOpen: false,
193 modelPickerOpen: false,
194 isWeb: !isElectron,
195 isDesktop: isElectron,
196 };
197}
198
199export interface PullRequestsSearch extends PullRequestListPreferences {
200 readonly mergebot?: EnvironmentId;
201 /**
202 * Narrows the list to one server. Absent means every connected one, which is the default the
203 * page has now — so a link written before servers could be chosen still opens the whole list.
204 */
205 readonly environmentId?: EnvironmentId;
206 /** Scopes the list. Separate from the selection so one cannot silently change the other. */
207 readonly projectId?: ProjectId;
208 /**
209 * Narrows the list to one host, named as the host itself: two GitHub installs are two
210 * accounts, and their shared provider kind cannot tell them apart. Absent means every host.
211 */
212 readonly host?: string;
213 readonly repository?: string;
214 readonly number?: number;
215 readonly selectedProjectId?: ProjectId;
216 /** Host of the open review; changing tabs must not narrow the list host filter. */
217 readonly selectedHost?: string;
218 /**
219 * Which server the selected pull request was read from. A project id only names a project on
220 * its own server, so this is what tells two servers holding one project apart. Optional: a
221 * link without it still opens, resolved by project id alone where that is unambiguous.
222 */
223 readonly selectedEnvironmentId?: EnvironmentId;
224}
225
226/**
227 * A group reads like the sidebar's shelves: its glyph, its name, how many, then a rule out
228 * to the edge. The glyph is the one the involvement filter uses for the same idea.
229 */
230const GROUP_ICONS: Record<string, LucideIcon> = {
231 authored: PenLineIcon,
232 reviewRequested: EyeIcon,
233 others: UsersIcon,
234};
235
236function PullRequestGroupHeader({
237 group,
238}: {
239 group: { key: string; label: string; entries: ReadonlyArray<unknown> };
240}) {
241 const Icon = GROUP_ICONS[group.key] ?? LayersIcon;
242 return (
243 <div className="flex items-center gap-2 px-3 pb-1 text-xs font-medium text-muted-foreground/70">
244 <Icon aria-hidden className="size-3.5 shrink-0" />
245 <h2 className="shrink-0">{group.label}</h2>
246 <span className="shrink-0 tabular-nums text-muted-foreground/50">{group.entries.length}</span>
247 <Separator className="min-w-2 flex-1" />
248 </div>
249 );
250}
251
252// The state filters wear the same glyphs the rows do, so the two read as one vocabulary.
253const INVOLVEMENT_TABS = [
254 { value: "all", label: "All", Icon: LayersIcon },
255 { value: "reviewing", label: "Reviewing", Icon: EyeIcon },
256 { value: "authored", label: "Authored", Icon: PenLineIcon },
257] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestInvolvement>>;
258
259const STATE_TABS = [
260 { value: "all", label: "All", Icon: LayersIcon },
261 { value: "open", label: "Open", Icon: PullRequestGlyph.pullRequest },
262 { value: "closed", label: "Closed", Icon: PullRequestGlyph.closed },
263 { value: "merged", label: "Merged", Icon: PullRequestGlyph.merged },
264] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;
265
266const SORT_OPTIONS = [
267 { value: "ready", label: "Merge readiness", Icon: ListChecksIcon },
268 { value: "blocked", label: "Blocked on me", Icon: UserLockIcon },
269 { value: "updated", label: "Recently updated", Icon: ClockIcon },
270 { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon },
271 { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon },
272 { value: "largest", label: "Largest shown", Icon: Maximize2Icon },
273 { value: "smallest", label: "Smallest shown", Icon: Minimize2Icon },
274] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListSort>>;
275
276/** Long enough that a keystroke does not become a request, short enough to feel answered. */
277const SEARCH_DEBOUNCE_MS = 250;
278/** What `scorePullRequestMatch` gives a row none of whose own fields carry the search text. */
279const MATCHED_ELSEWHERE_SCORE = 10;
280/**
281 * One whole page from the host and no more: every provider asks for one row beyond the page as
282 * its "is there more" probe, and GitHub serves a hundred per request — so asking for ninety-nine
283 * costs one round trip where a hundred costs two.
284 */
285const PAGE_SIZE = 99;
286/** The largest page the listing accepts; past it the request is refused outright. */
287const MAX_PAGE_SIZE = 500;
288/** Stable empty map so the memos below do not see a new object on every render. */
289const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {};
290/** Stable so a read that is not wanted right now does not re-key on every render. */
291const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = [];
292const EMPTY_PREVIEW_SESSIONS = {};
293const EMPTY_PREVIEW_DESKTOP_STATE = {};
294const EMPTY_TERMINAL_LABELS = new Map<string, string>();
295const EMPTY_PENDING_SURFACES = new Set<string>();
296const MAX_SEARCH_LABEL_CANDIDATES = 100;
297
298const pullRequestListEntryId = (target: Parameters<typeof pullRequestSurfaceId>[0]) =>
299 pullRequestSurfaceId({ ...target, repository: target.repository.toLowerCase() });
300
301function pullRequestSearchLabels(raw: unknown): Partial<Pick<PullRequestsSearch, "labels">> {
302 const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice(
303 0,
304 MAX_SEARCH_LABEL_CANDIDATES,
305 );
306 const labels: Array<string> = [];
307 const seen = new Set<string>();
308 for (const rawValue of values) {
309 if (typeof rawValue !== "string") continue;
310 const value = rawValue.trim().slice(0, 200);
311 const key = value.toLowerCase();
312 if (value.length === 0 || seen.has(key)) continue;
313 labels.push(value);
314 seen.add(key);
315 if (labels.length === 10) break;
316 }
317 return labels.length === 0 ? {} : { labels };
318}
319
320export const Route = createFileRoute("/_chat/pull-requests")({
321 validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({
322 ...(typeof raw.mergebot === "string" && raw.mergebot
323 ? { mergebot: raw.mergebot as EnvironmentId }
324 : {}),
325 involvement:
326 raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all",
327 state:
328 raw.state === "closed" || raw.state === "merged" || raw.state === "all" ? raw.state : "open",
329 ...(SORT_OPTIONS.some((option) => option.value === raw.sort)
330 ? { sort: raw.sort as PullRequestListSort }
331 : {}),
332 ...(typeof raw.repository === "string" && raw.repository
333 ? { repository: raw.repository.slice(0, 200) }
334 : {}),
335 ...(typeof raw.number === "number" && Number.isInteger(raw.number) && raw.number > 0
336 ? { number: raw.number }
337 : {}),
338 ...(typeof raw.projectId === "string" && raw.projectId
339 ? { projectId: raw.projectId as ProjectId }
340 : {}),
341 ...(typeof raw.environmentId === "string" && raw.environmentId
342 ? { environmentId: raw.environmentId as EnvironmentId }
343 : {}),
344 ...(typeof raw.host === "string" && raw.host ? { host: raw.host.slice(0, 200) } : {}),
345 ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId
346 ? { selectedProjectId: raw.selectedProjectId as ProjectId }
347 : {}),
348 ...(typeof raw.selectedHost === "string" && raw.selectedHost
349 ? { selectedHost: raw.selectedHost.slice(0, 200) }
350 : {}),
351 ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId
352 ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId }
353 : {}),
354 // Always an own key: the router lays this result over the parent route's unvalidated
355 // search, so leaving out a `q` the URL parser made a number would let the number through.
356 q: typeof raw.q === "string" && raw.q ? raw.q.slice(0, 200) : undefined,
357 ...(raw.draft === "only" || raw.draft === "hide" ? { draft: raw.draft } : {}),
358 ...(raw.review === "approved" ||
359 raw.review === "changes-requested" ||
360 raw.review === "review-required" ||
361 raw.review === "none"
362 ? { review: raw.review }
363 : {}),
364 ...(raw.checks === "passing" || raw.checks === "failing" ? { checks: raw.checks } : {}),
365 ...(typeof raw.author === "string" && raw.author.trim()
366 ? { author: raw.author.trim().slice(0, 200) }
367 : {}),
368 ...pullRequestSearchLabels(raw.labels),
369 }),
370 component: PullRequestsRouteView,
371});
372
373function PullRequestsRouteView() {
374 useEscapeToGoBack();
375 const search = Route.useSearch();
376 const environment = useEnvironment(search.mergebot ?? null);
377 const navigate = useNavigate({ from: Route.fullPath });
378 if (!search.mergebot) return <NativePullRequestsRouteView />;
379 const configuration = environment?.serverConfig?.mergebot;
380 const url =
381 configuration && search.repository && search.number
382 ? mergebotPullRequestUrl(configuration, {
383 repository: search.repository,
384 number: search.number,
385 })
386 : configuration
387 ? `${configuration.origin}/`
388 : undefined;
389 return (
390 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">
391 <WorkspacePageHeader electron={isElectron} reserveNativeControls>
392 <Button
393 variant="ghost"
394 size="sm"
395 onClick={() => void navigate({ search: { involvement: "all", state: "open" } })}
396 >
397 Pull requests
398 </Button>
399 <h1 className="truncate text-sm">Mergebot</h1>
400 </WorkspacePageHeader>
401 {url ? (
402 <MergebotBrowserPanel
403 key={`${search.mergebot}:${url}`}
404 threadRef={scopeThreadRef(search.mergebot, ThreadId.make(MERGEBOT_BROWSER_THREAD_ID))}
405 url={url}
406 />
407 ) : (
408 <PullRequestsUnavailableState
409 title="Mergebot unavailable"
410 error="Connect to the configured environment to open Mergebot."
411 />
412 )}
413 </SidebarInset>
414 );
415}
416
417function NativePullRequestsRouteView() {
418 const closeMergebotPreview = useAtomCommand(previewEnvironment.close);
419 const modifiers = useShortcutModifierState(true);
420 const speedMode =
421 modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey;
422 const search = Route.useSearch();
423 const sort = search.sort ?? "ready";
424 const statsPolicy: PullRequestStatsPolicy =
425 sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible";
426 const navigate = useNavigate({ from: Route.fullPath });
427 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
428 const { environments } = useEnvironments();
429 // Every connected environment that has said it can list pull requests. Sorted, so the query
430 // keys, the scope key and the stored snapshot all read the same whichever order the
431 // connections happened to come up in.
432 const capableEnvironments = useMemo(
433 () =>
434 environments
435 .filter(
436 (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true,
437 )
438 .toSorted((left, right) => left.environmentId.localeCompare(right.environmentId)),
439 [environments],
440 );
441 // The server the URL asks for, kept only while it is one the page could read: a link naming a
442 // server this workspace no longer has falls back to all of them rather than to nothing.
443 const scopedEnvironmentId =
444 capableEnvironments.find((environment) => environment.environmentId === search.environmentId)
445 ?.environmentId ?? null;
446 // Every server this workspace has ever heard of, connecting or not — wider than
447 // `capableEnvironments`, which only holds the ones ready to answer.
448 const knownEnvironmentIds = useMemo(
449 () => new Set(environments.map((environment) => environment.environmentId)),
450 [environments],
451 );
452 const environmentIds = useMemo(
453 () =>
454 capableEnvironments
455 .filter(
456 (environment) =>
457 scopedEnvironmentId === null || environment.environmentId === scopedEnvironmentId,
458 )
459 .map((environment) => environment.environmentId),
460 [capableEnvironments, scopedEnvironmentId],
461 );
462 const environmentKey = useMemo(
463 () => pullRequestEnvironmentSetKey(environmentIds),
464 [environmentIds],
465 );
466 // An environment may still be connecting, or may predate this feature. Until at least one has
467 // reported, an empty set means "not known yet" rather than "no environment can", and the page
468 // waits rather than telling a reader to upgrade a server that has not spoken.
469 const capabilityKnown = environments.some((environment) => environment.serverConfig !== null);
470 const pullRequestsSupported = environmentIds.length > 0;
471 const allProjects = useProjects();
472 // Whether the workspace has said what it holds yet. Until it has, an empty project list is
473 // "not loaded" rather than "none", and telling a reader to add a project they already have is
474 // the one wrong answer the empty state can give.
475 const projectsKnown = useAllEnvironmentShellsBootstrapped();
476 // Only the projects the page can actually read: one on an environment that cannot list pull
477 // requests could neither be listed nor acted on, and the Scratch folder is never a repository.
478 const projects = useMemo(
479 () =>
480 allProjects.filter(
481 (project) => !project.isScratch && environmentIds.includes(project.environmentId),
482 ),
483 [allProjects, environmentIds],
484 );
485 const environmentLabels = useMemo(
486 () =>
487 new Map(
488 environments.map((environment) => [environment.environmentId, environment.label] as const),
489 ),
490 [environments],
491 );
492 // The scope the URL asks for, once the environments have had their say about whether it exists.
493 const scopedProjectId = useMemo(
494 () => resolveProjectScope(search.projectId, projects, projectsKnown),
495 [projects, projectsKnown, search.projectId],
496 );
497 const scopedProject = useMemo(
498 () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId),
499 [projects, scopedEnvironmentId, scopedProjectId],
500 );
501 const scopedProjects = useMemo(
502 () => pullRequestFilterProjects(projects, environmentLabels, scopedProject),
503 [environmentLabels, projects, scopedProject],
504 );
505
506 // A link from a thread or the sidebar only knows the repository, so the owning project is
507 // resolved here; an explicit `projectId` in the URL still wins.
508 const selectedHost = search.selectedHost ?? search.host;
509 const projectIdForRepository = useMemo(() => {
510 const repository = search.repository?.toLowerCase();
511 if (repository === undefined) return undefined;
512 const identity = projects.find(
513 (project) =>
514 project.repositoryIdentity?.owner &&
515 project.repositoryIdentity.name &&
516 `${project.repositoryIdentity.owner}/${project.repositoryIdentity.name}`.toLowerCase() ===
517 repository &&
518 // The same `owner/name` can exist on two hosts. Without this the first match wins, and
519 // a link that named its host opens the pull request from the other one.
520 (selectedHost === undefined ||
521 pullRequestHostOf(
522 project.repositoryIdentity,
523 project.repositoryIdentity.provider as SourceControlProviderKind,
524 ) === selectedHost.toLowerCase()),
525 );
526 return identity?.id;
527 }, [projects, selectedHost, search.repository]);
528
529 // The selection is resolved the same way the scope is: an id no connected environment has can
530 // never be read here, and one that arrived before the projects did is not yet wrong.
531 const linkedProjectId = useMemo(
532 () => resolveProjectScope(search.selectedProjectId, projects, projectsKnown),
533 [projects, projectsKnown, search.selectedProjectId],
534 );
535 // The scope filter stands in as a last resort: a link can carry `projectId` with a repository
536 // whose identity the inference above cannot match, and refusing to open it because of the
537 // weaker signal would ignore the stronger one the URL spelled out.
538 const selectedProjectId = linkedProjectId ?? projectIdForRepository ?? scopedProjectId;
539 // Which server the selection belongs to. Named by the URL where a link had one to give;
540 // otherwise the project id decides, and only where exactly one server has that project.
541 // A named server still connecting is kept named rather than falling back: falling back while
542 // it is merely not ready yet would resolve the project against whatever other server has
543 // answered, which can be the wrong one where two servers share a project id. Only a name this
544 // workspace has never heard of falls back to the scope above, the same way that scope treats a
545 // stale name — a saved link naming a server that has since gone would otherwise open nothing,
546 // even where the project id it also carries names exactly one remaining server.
547 const selectedEnvironmentId = resolveSelectedEnvironmentId(
548 search.selectedEnvironmentId,
549 knownEnvironmentIds,
550 scopedEnvironmentId,
551 );
552 const selectedProject = useMemo(
553 () => findScopedProject(projects, selectedEnvironmentId, selectedProjectId),
554 [projects, selectedEnvironmentId, selectedProjectId],
555 );
556 // One panel for the page rather than one per server: the surfaces carry the server they were
557 // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref
558 // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab
559 // strip survives a capable server disconnecting or losing the pull-requests capability.
560 const rightPanelRef = capableEnvironments.length === 0 ? null : PULL_REQUESTS_PANEL_REF;
561 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(rightPanelRef);
562 const rightPanelState = useRightPanelStore((state) =>
563 selectThreadRightPanelState(state.byThreadKey, rightPanelRef),
564 );
565 const selectedRightPanelSurface = useRightPanelStore((state) =>
566 selectSelectedRightPanelSurface(state.byThreadKey, rightPanelRef),
567 );
568 const selectedPullRequestSurface =
569 selectedRightPanelSurface?.kind === "pull-request" ? selectedRightPanelSurface : null;
570 const activePullRequestSurface = rightPanelState.isOpen ? selectedPullRequestSurface : null;
571 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
572 usePanelAnimationSettings();
573 const rightPanelPresenceValue = useMemo(
574 () => ({
575 activeSurface: selectedPullRequestSurface,
576 surfaces: rightPanelState.surfaces,
577 }),
578 [rightPanelState.surfaces, selectedPullRequestSurface],
579 );
580 const rightPanelPresence = usePanelPresence(
581 rightPanelState.isOpen && selectedPullRequestSurface !== null,
582 rightPanelPresenceValue,
583 panelAnimationsActive,
584 rightPanelRef?.threadId ?? null,
585 panelAnimationDurationMs,
586 );
587 const rightPanelPresent = rightPanelPresence.present;
588 const renderedPullRequestSurface = rightPanelPresence.value?.activeSurface ?? null;
589 const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];
590 // The open tab names its own server; a link that arrived before any tab was opened names it
591 // through the project it selected.
592 const panelEnvironmentId =
593 (renderedPullRequestSurface?.environmentId as EnvironmentId | undefined) ??
594 selectedProject?.environmentId ??
595 null;
596 const updateSearch = useCallback(
597 (patch: {
598 [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined;
599 }) =>
600 void navigate({
601 // Rebuilt rather than spread so a cleared field leaves the URL instead of
602 // lingering as an explicit `undefined`.
603 search: (previous: PullRequestsSearch): PullRequestsSearch => {
604 const next = { ...previous, ...patch };
605 return {
606 involvement: next.involvement ?? previous.involvement,
607 state: next.state ?? previous.state,
608 ...(next.sort && next.sort !== "ready" ? { sort: next.sort } : {}),
609 ...(next.repository ? { repository: next.repository } : {}),
610 ...(next.number ? { number: next.number } : {}),
611 ...(next.projectId ? { projectId: next.projectId } : {}),
612 ...(next.environmentId ? { environmentId: next.environmentId } : {}),
613 ...(next.host ? { host: next.host } : {}),
614 ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}),
615 ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}),
616 ...(next.selectedEnvironmentId
617 ? { selectedEnvironmentId: next.selectedEnvironmentId }
618 : {}),
619 ...(next.q ? { q: next.q } : {}),
620 ...(next.draft ? { draft: next.draft } : {}),
621 ...(next.review ? { review: next.review } : {}),
622 ...(next.checks ? { checks: next.checks } : {}),
623 ...(next.author ? { author: next.author } : {}),
624 ...(next.labels && next.labels.length > 0 ? { labels: next.labels } : {}),
625 };
626 },
627 replace: true,
628 }),
629 [navigate],
630 );
631
632 const clearedSelection = {
633 repository: undefined,
634 number: undefined,
635 selectedProjectId: undefined,
636 selectedEnvironmentId: undefined,
637 selectedHost: undefined,
638 };
639 // List controls change the rows behind the detail, not the independent selected surface. The
640 // reader can keep working in that panel while narrowing, sorting, or switching projects.
641 const updateListScope = (patch: PullRequestListPreferencePatch) => {
642 const currentPreferences = pullRequestListPreferences({
643 ...search,
644 ...patch,
645 involvement: patch.involvement ?? search.involvement,
646 state: patch.state ?? search.state,
647 });
648 // Opening a selected-only link does not save its default all/open list. Once a list control
649 // changes, the entire visible scope is intentional and becomes the next sidebar destination.
650 writePullRequestListPreferences(currentPreferences);
651 updateSearch(patch);
652 };
653
654 // Searching asks the hosts, which takes a round trip, so the text is held for a moment before
655 // it is sent. Until it lands, the rows already on screen are narrowed locally: the answer is
656 // late but the page is not.
657 const typedQuery = (search.q ?? "").trim();
658 const [filtersOpen, setFiltersOpen] = useState(false);
659 const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS);
660 const querySettled = typedQuery === sentQuery;
661 // What was typed, split into the qualifiers the hosts can act on and the words that are left.
662 // The URL keeps the line as it was written; only the request sees the two halves.
663 const typedParsed = useMemo(() => parsePullRequestQuery(typedQuery), [typedQuery]);
664 const sentParsed = useMemo(() => parsePullRequestQuery(sentQuery), [sentQuery]);
665
666 // One record for the hosts, rebuilt only when the URL's own fields change so the listing
667 // inputs stay identical between renders.
668 const menuFilters = useMemo(
669 (): PullRequestListFilters => ({
670 ...(search.draft ? { draft: search.draft } : {}),
671 ...(search.review ? { review: search.review } : {}),
672 ...(search.checks ? { checks: search.checks } : {}),
673 ...(search.author ? { author: search.author } : {}),
674 ...(search.labels ? { labels: search.labels.map((label) => [label]) } : {}),
675 }),
676 [search.author, search.checks, search.draft, search.labels, search.review],
677 );
678 const menuFiltered = Object.keys(menuFilters).length > 0;
679 // A typed qualifier wins over the menu's own answer for the same thing, since it is the more
680 // recent word on it, including a typed author or label over its menu counterpart.
681 const filters = useMemo(
682 (): PullRequestListFilters => ({ ...menuFilters, ...sentParsed.filters }),
683 [menuFilters, sentParsed.filters],
684 );
685 const hasFilters = Object.keys(filters).length > 0;
686 /** The same narrowings the moment they are typed, for the rows already on screen. */
687 const localFilters = useMemo(
688 (): PullRequestListFilters => ({ ...menuFilters, ...typedParsed.filters }),
689 [menuFilters, typedParsed.filters],
690 );
691 const hasLocalFilters = Object.keys(localFilters).length > 0;
692 // Scoping to a project scopes to the server that owns it, saving every other server a read
693 // that could only answer with nothing. Where an id is ambiguous — two servers holding the
694 // same project id, with no server named — only the servers that actually hold that id are
695 // asked: a server without it may still hold an unrelated project of its own under the same
696 // string, and asking it would return that project's rows rather than an honest empty answer.
697 const queryEnvironmentIds = useMemo(
698 () =>
699 resolveQueryEnvironmentIds(
700 environmentIds,
701 projects,
702 scopedProject,
703 scopedProjectId,
704 projectsKnown,
705 ),
706 [environmentIds, projects, projectsKnown, scopedProject, scopedProjectId],
707 );
708 /**
709 * Which projects each server is asked about. Two servers holding the same repository would both
710 * list the same pull requests, so each repository is listed by one of them — the first, which is
711 * where the page's actions land — and the others are asked only for what is theirs alone. A
712 * server left with nothing of its own is not read at all.
713 *
714 * Left alone while the projects are still arriving, and while the scope is a single project:
715 * that path deliberately asks both servers holding an ambiguous id.
716 */
717 const environmentQueries = useMemo((): ReadonlyArray<{
718 readonly environmentId: EnvironmentId;
719 readonly projectIds?: ReadonlyArray<ProjectId>;
720 }> => {
721 const plain = queryEnvironmentIds.map((environmentId) => ({ environmentId }));
722 if (!projectsKnown || scopedProjectId !== undefined) return plain;
723 const assignment = assignProjectsToEnvironments(
724 projects,
725 queryEnvironmentIds,
726 queryEnvironmentIds[0],
727 );
728 const totals = new Map<EnvironmentId, number>();
729 for (const project of projects) {
730 totals.set(project.environmentId, (totals.get(project.environmentId) ?? 0) + 1);
731 }
732 return queryEnvironmentIds.flatMap((environmentId) => {
733 const projectIds = assignment.get(environmentId);
734 if (projectIds === undefined) return [];
735 // It lists everything it holds anyway, so the filter is left off and a one-server workspace
736 // asks exactly the question it asked before.
737 if (projectIds.length === (totals.get(environmentId) ?? 0)) return [{ environmentId }];
738 return [{ environmentId, projectIds }];
739 });
740 }, [projects, projectsKnown, queryEnvironmentIds, scopedProjectId]);
741 // Part of the scope, since a different split is a different question and its answers must not
742 // be filed under the same page state.
743 const assignmentKey = useMemo(
744 () =>
745 environmentQueries
746 .map(({ environmentId, projectIds }) => `${environmentId}#${projectIds?.join("+") ?? "*"}`)
747 .join("|"),
748 [environmentQueries],
749 );
750 const turnRefreshes = usePullRequestTurnRefreshes(
751 environmentQueries.map(({ environmentId }) => environmentId),
752 );
753 const turnRefreshToken = turnRefreshes
754 .map(([environmentId, revision]) => `${environmentId}:${revision}`)
755 .join("|");
756 // Page size is view state, not a URL concern: a shared link should open the first page.
757 const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}:${search.author ?? ""}:${search.labels?.join("\u0000") ?? ""}`;
758 const filterKey = `${scopeKey}:${sentQuery}`;
759 const statsScopeRef = useRef<PullRequestStatsScope>({ key: filterKey, policy: statsPolicy });
760 statsScopeRef.current = { key: filterKey, policy: statsPolicy };
761 // Where the next slice carries on from, per repository within each environment, as that
762 // environment handed it back. Sending it is what makes a second page cost a second page rather
763 // than the whole list again — and a repository it does not name has run out and is not read a
764 // second time. An environment absent from it has nothing more to give at all.
765 const [page, setPage] = useState<{
766 key: string;
767 size: number;
768 cursors: Readonly<Record<string, PullRequestListCursors>> | null;
769 /**
770 * The environments read again from the top at the larger page rather than continued. An
771 * environment can say it has more without saying where to carry on from — its provider has
772 * no cursor to give — and a continuation naming only the others would strand its rows on
773 * the host forever.
774 */
775 regrown: ReadonlyArray<string>;
776 }>({ key: filterKey, size: PAGE_SIZE, cursors: null, regrown: [] });
777 const pageSize = page.key === filterKey ? page.size : PAGE_SIZE;
778 const sentCursors = page.key === filterKey ? page.cursors : null;
779 const sentRegrown = page.key === filterKey ? page.regrown : [];
780
781 // Typing a search, or clearing one, starts the list again at its first page. Without this the
782 // paging state from before the search is still filed under these filters and comes back with
783 // it, so clearing would return to the slice that had been scrolled to rather than to the list.
784 useEffect(() => {
785 setPage({ key: filterKey, size: PAGE_SIZE, cursors: null, regrown: [] });
786 }, [filterKey]);
787
788 /** The listing input each environment is asked for, which differs only in its continuation. */
789 const listTargets = useMemo(
790 () =>
791 environmentQueries.flatMap(({ environmentId, projectIds }) => {
792 const cursors = sentCursors?.[environmentId];
793 // A continuation asks the environments that said where to carry on from, plus the ones
794 // that have more to give but no cursor to give it from — those are read again at the
795 // larger page. The rest have run out, and re-reading them would answer with the page
796 // that is already on screen.
797 if (sentCursors !== null && cursors === undefined && !sentRegrown.includes(environmentId)) {
798 return [];
799 }
800 return [
801 {
802 environmentId,
803 input: {
804 state: search.state,
805 // The hosts narrow by involvement themselves — GitHub by author and review
806 // request, and so on — so asking them is the difference between a page of results
807 // and a page of everything with the answer somewhere further down it.
808 involvement: search.involvement,
809 limit: pageSize,
810 ...(scopedProjectId ? { projectId: scopedProjectId } : {}),
811 ...(projectIds ? { projectIds } : {}),
812 ...(search.host ? { host: search.host } : {}),
813 ...(hasFilters ? { filters } : {}),
814 ...(sentParsed.text ? { query: sentParsed.text } : {}),
815 ...(cursors === undefined ? {} : { cursors }),
816 } satisfies PullRequestListInput,
817 },
818 ];
819 }),
820 [
821 filters,
822 hasFilters,
823 pageSize,
824 environmentQueries,
825 scopedProjectId,
826 search.host,
827 search.involvement,
828 search.state,
829 sentCursors,
830 sentRegrown,
831 sentParsed.text,
832 ],
833 );
834 const listQuery = usePullRequestList(listTargets);
835
836 /**
837 * The same filters with nothing typed, read whether or not anything is. It is the same atom the
838 * list itself uses while no search is on, so it costs nothing then; while one is, it is what the
839 * page knows about the workspace — who is signed in, which hosts there are, which repositories
840 * could not be read — and what it shows the moment the search is cleared.
841 *
842 * Kept as its own read rather than remembered from an earlier answer because an answer cannot
843 * say which question it belongs to: mid-switch the text has already changed and the data has
844 * not, and a search's answer would file itself under the workspace.
845 */
846 const baselineTargets = useMemo(
847 () =>
848 environmentQueries.map(({ environmentId, projectIds }) => ({
849 environmentId,
850 input: {
851 state: search.state,
852 involvement: search.involvement,
853 limit: PAGE_SIZE,
854 ...(scopedProjectId ? { projectId: scopedProjectId } : {}),
855 ...(projectIds ? { projectIds } : {}),
856 ...(search.host ? { host: search.host } : {}),
857 ...(menuFiltered ? { filters: menuFilters } : {}),
858 } satisfies PullRequestListInput,
859 })),
860 [
861 menuFiltered,
862 menuFilters,
863 environmentQueries,
864 scopedProjectId,
865 search.host,
866 search.involvement,
867 search.state,
868 ],
869 );
870 const baselineQuery = usePullRequestList(baselineTargets);
871 const baselineEmpty =
872 baselineQuery.data?.entries.length === 0 &&
873 baselineQuery.data.errors.length === 0 &&
874 !baselineQuery.isPending &&
875 baselineQuery.error === null;
876 const facetTargets = useMemo(() => {
877 if (!filtersOpen) return NO_LIST_TARGETS;
878 return environmentQueries.map(({ environmentId, projectIds }) => ({
879 environmentId,
880 input: {
881 state: "all",
882 involvement: search.involvement,
883 limit: PAGE_SIZE,
884 ...(scopedProjectId ? { projectId: scopedProjectId } : {}),
885 ...(projectIds ? { projectIds } : {}),
886 ...(search.host ? { host: search.host } : {}),
887 } satisfies PullRequestListInput,
888 }));
889 }, [environmentQueries, filtersOpen, scopedProjectId, search.host, search.involvement]);
890 const facetQuery = usePullRequestList(facetTargets);
891 // The priority groups' own reads. The feed below is paginated by recency, so an older authored
892 // or review-requested row can be missing from its first page; partitioned from these
893 // server-filtered reads instead, the priority view is complete up front and a continuation can
894 // only ever append below what is already on screen. A search re-ranks the whole list by match,
895 // so no partitions are read for one. These are the same atoms the Authored and Reviewing tabs
896 // ask for, so switching to either is answered from cache.
897 const partitionsWanted = search.involvement === "all" && typedQuery.length === 0;
898 // Built together so the two reads share one memo, and in the same field order the feed's own
899 // input uses: the atoms are keyed by their input, so the Authored tab then reads this answer.
900 const partitionTargets = useMemo(() => {
901 // The main list goes first. Besides putting the visible rows on screen sooner, it proves
902 // which repositories the host search indexes, so an empty partition does not trigger the
903 // expensive per-repository fallback. With no rows at all both partitions are already empty.
904 if (
905 !partitionsWanted ||
906 baselineQuery.data === null ||
907 baselineQuery.data.entries.length === 0
908 ) {
909 return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS };
910 }
911 const targetsFor = (involvement: PullRequestInvolvement) =>
912 environmentQueries.map(({ environmentId, projectIds }) => ({
913 environmentId,
914 input: {
915 state: search.state,
916 involvement,
917 limit: PAGE_SIZE,
918 ...(scopedProjectId ? { projectId: scopedProjectId } : {}),
919 ...(projectIds ? { projectIds } : {}),
920 ...(search.host ? { host: search.host } : {}),
921 ...(menuFiltered ? { filters: menuFilters } : {}),
922 } satisfies PullRequestListInput,
923 }));
924 return { authored: targetsFor("authored"), reviewing: targetsFor("reviewing") };
925 }, [
926 menuFiltered,
927 menuFilters,
928 partitionsWanted,
929 baselineQuery.data,
930 environmentQueries,
931 scopedProjectId,
932 search.host,
933 search.state,
934 ]);
935 const authoredQuery = usePullRequestList(partitionTargets.authored);
936 const reviewingQuery = usePullRequestList(partitionTargets.reviewing);
937 // The header's refresh punches through the server's cache before re-reading; the error and
938 // empty states retry plainly, because a failure is never cached.
939 const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });
940 // What the reader pressed refresh for is everything they can see, not the one query that
941 // happens to be theirs: the list, the counts beside its rows, and whatever the panel is
942 // showing. The panel owns its own reads, so it is told to redo them rather than reached into.
943 const [detailRefreshToken, setDetailRefreshToken] = useState(0);
944 // The queries only go pending once the invalidation has come back, so refreshing is tracked
945 // from the first moment rather than the second: a button that stays live through the slow half
946 // of its own work is a button that gets pressed again, and buys the whole cascade twice.
947 const [invalidating, setInvalidating] = useState(false);
948 const refreshListAndStats = (
949 requestedStatsScope = statsScopeRef.current,
950 actedEnvironmentId?: EnvironmentId,
951 ) => {
952 refreshList(true, actedEnvironmentId);
953 const visible = visibleStatsKeys.current;
954 const batches = pullRequestStatsRefreshBatches({
955 requestedScope: requestedStatsScope,
956 currentScope: statsScopeRef.current,
957 entriesByKey: entriesByStatsKey.current,
958 candidateKeys: visible.key === requestedStatsScope.key ? visible.values : new Set(),
959 statsByRow: statsByRowRef.current,
960 });
961 if (batches !== null) {
962 setStatsTargetState({ key: requestedStatsScope.key, batches });
963 statsQuery.refresh(
964 batches
965 .filter(({ environmentId }) =>
966 actedEnvironmentId === undefined ? true : environmentId === actedEnvironmentId,
967 )
968 .map(({ environmentId, input }) => ({ environmentId, input })),
969 );
970 }
971 };
972 const refreshFromHost = async () => {
973 const requestedStatsScope = statsScopeRef.current;
974 setInvalidating(true);
975 try {
976 // Every environment the page is reading, since what the reader pressed refresh for is the
977 // list in front of them rather than whichever machine happens to be first.
978 await Promise.all(
979 queryEnvironmentIds.map((environmentId) => invalidate({ environmentId, input: {} })),
980 );
981 } finally {
982 setInvalidating(false);
983 }
984 refreshListAndStats(requestedStatsScope);
985 setDetailRefreshToken((token) => token + 1);
986 };
987 const refreshing = invalidating || listQuery.isPending;
988
989 // Every page size and every search is its own query, and a new one starts empty. The last
990 // answer for these filters is held so the page grows and narrows in place rather than blanking
991 // out: a longer page reads as growth, and a search shows the rows it already has, narrowed
992 // here, until the hosts answer for themselves.
993 const [loaded, setLoaded] = useState<{
994 environmentKey: string;
995 scope: string;
996 query: string;
997 data: MergedPullRequestList;
998 /** The priority groups' own answers, carried so a cold start has whole groups too. */
999 partitions?: PullRequestPartitionsSnapshot;
1000 } | null>(null);
1001 // A longer page is the same list with more on the end, so the rows already read stay where
1002 // they were read: each answer is merged onto the last rather than replacing it, and anything
1003 // new lands at the bottom. Only a different question — other filters, another search — starts
1004 // the order again. Declared here, ahead of the snapshot write below, so that write can read
1005 // this round's accumulation rather than only its own slice.
1006 const [ordered, setOrdered] = useState<{
1007 key: string;
1008 entries: ReadonlyArray<EnvironmentPullRequestEntry>;
1009 } | null>(null);
1010 // What the reader just did to a row, shown before any host confirms it. A closed pull request
1011 // leaves an "open" list on the click; the reads that follow are slow, and until one lands the
1012 // row says what was asked of it. Cleared when a whole-page answer arrives after the action.
1013 const [overrides, setOverrides] = useState<ReadonlyMap<string, PullRequestListOverride>>(
1014 () => new Map(),
1015 );
1016 const overrideToken = useRef(0);
1017 /** Writes the action's outcome onto the row; the token names this write for a later rollback. */
1018 const overrideEntry = (
1019 entry: EnvironmentPullRequestEntry,
1020 action: PullRequestAction,
1021 ): number | null => {
1022 const token = ++overrideToken.current;
1023 const override = pullRequestOverrideAfterAction(entry, action, new Date(), token);
1024 if (override === null) return null;
1025 setOverrides((current) => new Map(current).set(pullRequestEntryKey(entry), override));
1026 return token;
1027 };
1028 /** A rollback for one write only: a later action's note over the same row is left alone. */
1029 const revertOverride = (key: string, token: number | null) => {
1030 if (token === null) return;
1031 setOverrides((current) => {
1032 if (current.get(key)?.token !== token) return current;
1033 const next = new Map(current);
1034 next.delete(key);
1035 return next;
1036 });
1037 };
1038 /** The detail panel's own writes, by row, so its failure takes back its own note. */
1039 const detailOverrideTokens = useRef(new Map<string, number | null>());
1040 const speedActionRef = useRef<(result: PullRequestSpeedActionResult) => void>(() => {});
1041 speedActionRef.current = ({ entry, action }) => {
1042 // Some hosts accept a merge before it completes. Let the next host read declare it merged.
1043 if (action !== "merge") overrideEntry(entry, action);
1044 };
1045 const onSpeedAction = useCallback((result: PullRequestSpeedActionResult) => {
1046 speedActionRef.current(result);
1047 }, []);
1048 const onBatchClosed = useCallback((entry: EnvironmentPullRequestEntry) => {
1049 speedActionRef.current({ entry, action: "close" });
1050 }, []);
1051 const { close: closeBatch, closingKeys } = usePullRequestCloseBatch(onBatchClosed);
1052 // A reload recreates the registry the queries live in, so with nothing held the page would
1053 // cold-start into skeletons even though almost every row is unchanged. The last answer for
1054 // this set of environments is kept across reloads and hydrated here as the carried rows: they
1055 // render at once — narrowed to the current filters like any carried answer — and the live read
1056 // reconciles them in place by key rather than replacing them with ghosts.
1057 useEffect(() => {
1058 if (environmentKey.length === 0) return;
1059 setLoaded((current) => {
1060 // Rows read from a different set of environments cannot even be narrowed — one of them may
1061 // no longer be connected at all — so that set's own snapshot beats holding them.
1062 if (current !== null && current.environmentKey === environmentKey) return current;
1063 const snapshot = readPullRequestListSnapshot(
1064 typeof window === "undefined" ? undefined : window.localStorage,
1065 environmentKey,
1066 );
1067 if (snapshot === null) return null;
1068 return {
1069 environmentKey,
1070 scope: snapshot.scope,
1071 query: "",
1072 data: snapshot.data,
1073 ...(snapshot.partitions === undefined ? {} : { partitions: snapshot.partitions }),
1074 };
1075 });
1076 }, [environmentKey]);
1077 useEffect(() => {
1078 // Only once this query has settled. While a search is being swapped in or out the text has
1079 // already changed and the data has not, so recording them together would file the previous
1080 // answer under the new question — which is how a search's answer came to speak for the
1081 // workspace after the search was cleared.
1082 if (!listQuery.data || listQuery.isPending) return;
1083 const data = listQuery.data;
1084 setLoaded((current) => {
1085 // The partitions arrive on their own clock, so this records whichever have landed by
1086 // now and runs again when the rest do. Until then the ones already held for this scope
1087 // stay — hydrated or previously answered — rather than being dropped for a feed that
1088 // merely settled first.
1089 const partitions =
1090 partitionsWanted && baselineEmpty
1091 ? { authored: [], reviewing: [] }
1092 : partitionsWanted && authoredQuery.data !== null && reviewingQuery.data !== null
1093 ? { authored: authoredQuery.data.entries, reviewing: reviewingQuery.data.entries }
1094 : current !== null &&
1095 current.environmentKey === environmentKey &&
1096 current.scope === scopeKey
1097 ? current.partitions
1098 : undefined;
1099 // A search's answer is the search's, not the workspace's, so only unsearched lists
1100 // persist. Written here where the held partitions are in reach, so a feed settling
1101 // ahead of them cannot overwrite a stored snapshot that already had both groups.
1102 //
1103 // What is written is what the reader is actually looking at, not this round's own
1104 // answer: a continuation only asks the environments still paging, so its answer alone is
1105 // missing both the rows read on earlier pages and the hosts of whichever environment had
1106 // already run out of cursors. `ordered` carries the accumulated rows, and the baseline
1107 // read — which never drops an environment for lack of a cursor — carries the full hosts.
1108 if (environmentKey.length > 0 && sentQuery.length === 0) {
1109 const accumulatedEntries = ordered?.key === filterKey ? ordered.entries : data.entries;
1110 writePullRequestListSnapshot(
1111 typeof window === "undefined" ? undefined : window.localStorage,
1112 environmentKey,
1113 {
1114 scope: scopeKey,
1115 data: {
1116 ...data,
1117 entries: accumulatedEntries,
1118 viewers: baselineQuery.data?.viewers ?? data.viewers,
1119 providers: baselineQuery.data?.providers ?? data.providers,
1120 },
1121 ...(partitions === undefined ? {} : { partitions }),
1122 },
1123 );
1124 }
1125 return {
1126 environmentKey,
1127 scope: scopeKey,
1128 query: sentQuery,
1129 data: { ...data, entries: ordered?.key === filterKey ? ordered.entries : data.entries },
1130 ...(partitions === undefined ? {} : { partitions }),
1131 };
1132 });
1133 }, [
1134 environmentKey,
1135 scopeKey,
1136 sentQuery,
1137 listQuery.data,
1138 listQuery.isPending,
1139 baselineEmpty,
1140 partitionsWanted,
1141 authoredQuery.data,
1142 reviewingQuery.data,
1143 ordered,
1144 filterKey,
1145 baselineQuery.data,
1146 ]);
1147 // Changing a filter asks a question nothing has answered yet, and the page is already holding
1148 // perfectly good rows for the last one. Rather than blank out for the round trip, those rows
1149 // are narrowed to the new filters and stay until the answer lands — a subset of it, never a
1150 // row it excludes. Narrowed to nothing there is nothing to carry, and the skeletons below are
1151 // right after all. Rows read from a different set of environments are dropped rather than
1152 // narrowed: one of those environments may no longer be connected.
1153 const narrowed = useMemo(() => {
1154 if (loaded === null || loaded.environmentKey !== environmentKey || loaded.scope === scopeKey) {
1155 return null;
1156 }
1157 const entries = narrowPullRequestsToFilters(loaded.data.entries, {
1158 state: search.state,
1159 projectId: scopedProjectId,
1160 host: search.host,
1161 });
1162 return entries.length === 0 ? null : { ...loaded.data, entries };
1163 }, [environmentKey, loaded, scopeKey, scopedProjectId, search.host, search.state]);
1164 // With nothing typed and nothing to carry on from, the answer is taken from the read that is
1165 // keyed to exactly that question. Otherwise a search's answer lingers for a render after the
1166 // text has gone — the data cannot say which question it belongs to, but the read it came from
1167 // can, and that is what stops a cleared search from keeping its own rows.
1168 // A grown page is read by the list and nothing else: the baseline always asks for one page, so
1169 // a host with no cursor to continue from — where "more" means asking for a longer page — would
1170 // have its extra rows thrown away for the ninety-nine the baseline keeps answering with.
1171 const answered =
1172 (sentQuery.length === 0 && sentCursors === null && pageSize === PAGE_SIZE
1173 ? baselineQuery.data
1174 : listQuery.data) ??
1175 (loaded?.scope === scopeKey && loaded.query === sentQuery ? loaded.data : null);
1176 // Clearing a search returns to a list that has already been read, so it comes back at once
1177 // rather than after another round trip: the search was the temporary state, not the list.
1178 const carried =
1179 (sentQuery.length === 0 ? baselineQuery.data : undefined) ??
1180 (loaded?.scope === scopeKey ? loaded.data : null) ??
1181 narrowed;
1182 const listData = answered ?? carried;
1183 /** The rows on screen answer the previous question, held while this one is on its way. */
1184 const showingCarried = answered === null && carried !== null;
1185 const loadingMore = listQuery.isPending && listData !== null;
1186 /** Nothing read and nothing to carry, which is the one thing skeletons are for. */
1187 const firstLoad = listQuery.isPending && listData === null;
1188
1189 // `ordered` is declared above, ahead of the snapshot write, but grown here from this round's
1190 // own answer.
1191 useEffect(() => {
1192 if (!answered || listQuery.isPending || (listQuery.error && listQuery.data === null)) return;
1193 setOrdered((previous) => {
1194 if (previous === null || previous.key !== filterKey) {
1195 return {
1196 key: filterKey,
1197 entries: rankPullRequestMatches(answered.entries, sentParsed.text),
1198 };
1199 }
1200 if (sentCursors !== null) {
1201 // A continuation is a slice, not the list: it carries only what comes after the rows
1202 // already held, and says nothing about a repository that has run out. Everything on
1203 // screen therefore stays, and the slice — ordered among itself, since one repository's
1204 // next rows can be newer than another's last — lands under it.
1205 const held = new Set(previous.entries.map(pullRequestEntryKey));
1206 const arrived = answered.entries.filter((entry) => !held.has(pullRequestEntryKey(entry)));
1207 const appended = rankPullRequestMatches(
1208 arrived.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
1209 sentParsed.text,
1210 );
1211 return { key: filterKey, entries: [...previous.entries, ...appended] };
1212 }
1213 // A whole-page answer replaces the order outright. Positions used to be inherited so the
1214 // list would not shift under a reader, but inheriting them filed a pull request opened
1215 // since the last read at the bottom of the page — below rows a week older — where "the
1216 // latest" is exactly what a refresh was for. The host answers in the order the page
1217 // reads, so its order stands; a row that moved was updated, and moving is the news.
1218 return {
1219 key: filterKey,
1220 entries: reusePullRequestEntries(
1221 previous.entries,
1222 rankPullRequestMatches(answered.entries, sentParsed.text),
1223 pullRequestEntryKey,
1224 ),
1225 };
1226 });
1227 // The host's word outranks the reader's, once it has actually said it: an override is
1228 // cleared by an answer that agrees with it, not by any answer that happens to land.
1229 setOverrides((current) =>
1230 settlePullRequestOverrides(current, answered.entries, pullRequestEntryKey, Date.now()),
1231 );
1232 }, [
1233 answered,
1234 filterKey,
1235 sentCursors,
1236 sentParsed.text,
1237 listQuery.isPending,
1238 listQuery.error,
1239 listQuery.data,
1240 ]);
1241
1242 // Carrying on where the last answer stopped, and only raising the page size for the hosts that
1243 // could not say where that was.
1244 // From this question's own answer, never from the rows being held while it travels: a
1245 // continuation is a boundary in one listing, and carrying one across a search would ask the
1246 // new question to start where the old one stopped — skipping its newest matches entirely.
1247 const nextCursors = answered?.nextCursors ?? {};
1248 // The environments with more rows and no cursor to reach them by. They come along with any
1249 // continuation, read again at a page one step larger, which is the only way they grow.
1250 const regrown = (answered?.truncatedEnvironments ?? []).filter(
1251 (environmentId) => nextCursors[environmentId] === undefined,
1252 );
1253 const canContinue = !showingCarried && Object.keys(nextCursors).length > 0;
1254 const loadMore = () => {
1255 if (canContinue) {
1256 setPage({
1257 key: filterKey,
1258 size: regrown.length === 0 ? pageSize : Math.min(pageSize + PAGE_SIZE, MAX_PAGE_SIZE),
1259 cursors: nextCursors,
1260 regrown,
1261 });
1262 return;
1263 }
1264 setPage({
1265 key: filterKey,
1266 size: Math.min(pageSize + PAGE_SIZE, MAX_PAGE_SIZE),
1267 cursors: null,
1268 regrown: [],
1269 });
1270 };
1271
1272 // A refresh means the whole visible list again, and a cursored query cannot answer that: it
1273 // re-reads only its own slice, so the rows loaded before it would never see a merge, a close,
1274 // or a retitle. Going back to a single page long enough to cover everything on screen lets the
1275 // merge above bring every row up to date in place.
1276 const refreshList = (includeRelated = false, actedEnvironmentId?: EnvironmentId) => {
1277 const related = (
1278 includeRelated
1279 ? [
1280 ...baselineTargets,
1281 ...facetTargets,
1282 ...partitionTargets.authored,
1283 ...partitionTargets.reviewing,
1284 ]
1285 : []
1286 ).filter(
1287 ({ environmentId }) =>
1288 actedEnvironmentId === undefined || environmentId === actedEnvironmentId,
1289 );
1290 if (sentCursors === null) {
1291 listQuery.refresh([
1292 ...listTargets.filter(
1293 ({ environmentId }) =>
1294 actedEnvironmentId === undefined || environmentId === actedEnvironmentId,
1295 ),
1296 ...related,
1297 ]);
1298 return;
1299 }
1300 if (related.length > 0) listQuery.refresh(related);
1301 const loadedCount = ordered?.key === filterKey ? ordered.entries.length : pageSize;
1302 setPage({
1303 key: filterKey,
1304 regrown: [],
1305 size: Math.min(
1306 Math.max(pageSize, Math.ceil(loadedCount / PAGE_SIZE) * PAGE_SIZE),
1307 MAX_PAGE_SIZE,
1308 ),
1309 cursors: null,
1310 });
1311 };
1312
1313 const appliedTurnRefreshToken = useRef("");
1314 const refreshAfterTurn = useEffectEvent(() => {
1315 if (sentCursors !== null) refreshList();
1316 });
1317 useEffect(() => {
1318 if (turnRefreshToken.length === 0 || appliedTurnRefreshToken.current === turnRefreshToken) {
1319 return;
1320 }
1321 appliedTurnRefreshToken.current = turnRefreshToken;
1322 refreshAfterTurn();
1323 }, [turnRefreshToken]);
1324
1325 // The list goes stale the same way the detail does: somebody opens a pull request, a check
1326 // finishes, a branch is merged. So it reads again on the way back to the window, and once a
1327 // minute while somebody is reading it. Those reads go through the server's cache and stop
1328 // when the reader stops, which is what keeps a page left open from spending a night of the
1329 // host's rate limit.
1330 useLiveRefresh(
1331 () => {
1332 refreshList(true);
1333 },
1334 { enabled: pullRequestsSupported },
1335 );
1336
1337 const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS;
1338 const listErrors = baselineQuery.data?.errors ?? listData?.errors ?? [];
1339 const facets = useMemo(
1340 () =>
1341 collectPullRequestListFacets(
1342 [
1343 ...(facetQuery.data?.entries ?? []),
1344 ...(baselineQuery.data?.entries ?? listData?.entries ?? []),
1345 ],
1346 search.state,
1347 ),
1348 [baselineQuery.data?.entries, facetQuery.data?.entries, listData?.entries, search.state],
1349 );
1350
1351 /** The hosts that narrowed the listing themselves, so their answer is not narrowed again. */
1352 const searchingHosts = useMemo(
1353 () =>
1354 new Set(
1355 (baselineQuery.data?.providers ?? listData?.providers ?? []).flatMap((provider) =>
1356 provider.searchesOnHost ? [provider.host] : [],
1357 ),
1358 ),
1359 [baselineQuery.data?.providers, listData?.providers],
1360 );
1361
1362 const entries = useMemo(() => {
1363 const known = ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []);
1364 const involvementEntries = filterPullRequestsByInvolvement(known, viewers, search.involvement);
1365 // The hosts search more than the row shows — a body, a review, a commit message — so once
1366 // their answer is in, narrowing it again here would throw away matches the reader asked for.
1367 // The local pass stands in for the answer that has not arrived yet, and for the hosts that
1368 // answered without searching at all: Azure DevOps has no text filter, so its rows arrive
1369 // whole and would otherwise sit under a search that never touched them.
1370 // The rows the hosts could not narrow themselves, for the fields a row actually carries:
1371 // a host with no filter of its own answers unnarrowed, and so does one whose answer for the
1372 // new filters has not arrived yet. Checks are absent here, because no row carries them.
1373 const narrowedEntries = hasLocalFilters
1374 ? involvementEntries.filter((entry) =>
1375 matchesPullRequestFilters(entry, localFilters, pullRequestEntryViewer(entry, viewers)),
1376 )
1377 : involvementEntries;
1378 // Newest update first once nothing is typed, so a list merged from several hosts reads in
1379 // one order rather than in each host's. With a search on, the relevance ranking above is
1380 // the order, and re-sorting by date here would undo it.
1381 if (typedParsed.text.length === 0) {
1382 return narrowedEntries.toSorted((left, right) =>
1383 right.updatedAt.localeCompare(left.updatedAt),
1384 );
1385 }
1386 const answeredLocally = querySettled && !showingCarried;
1387 return narrowedEntries.filter(
1388 (entry) =>
1389 (answeredLocally && searchingHosts.has(entry.host)) ||
1390 matchesPullRequestQuery(entry, typedParsed.text),
1391 );
1392 }, [
1393 filterKey,
1394 hasLocalFilters,
1395 localFilters,
1396 listData,
1397 ordered,
1398 querySettled,
1399 search.involvement,
1400 searchingHosts,
1401 showingCarried,
1402 typedParsed.text,
1403 viewers,
1404 ]);
1405
1406 // Seed the first tab paint from list rows while each tab's cached detail read lands.
1407 const listedPullRequestTabStatuses = useMemo<Record<string, PullRequestTabStatusSeed>>(
1408 () =>
1409 Object.fromEntries(
1410 entries.map((entry) => [
1411 pullRequestSurfaceId(entry),
1412 {
1413 state: entry.state,
1414 isDraft: entry.isDraft,
1415 },
1416 ]),
1417 ),
1418 [entries],
1419 );
1420
1421 const scrollRef = useRef<HTMLDivElement>(null);
1422
1423 /**
1424 * The line counts, asked for once the rows are on screen. On GitHub they are forty per cent of
1425 * the read that answers the whole page, for two small numbers at the end of a row — so the
1426 * listing leaves them out and they arrive a moment later, into rows that already draw without
1427 * them. Keyed by the rows being shown, so scrolling further asks only about what is new.
1428 */
1429 const groups = useMemo(() => {
1430 if (search.involvement !== "all") return [{ key: "others" as const, label: "", entries }];
1431 // An empty whole-list answer also empties the priority groups. Their old snapshot must
1432 // not restore the last merged rows after the partition reads are no longer mounted.
1433 if (baselineEmpty) {
1434 return groupPullRequestsByInvolvement(entries, viewers);
1435 }
1436 // Until both partitions have answered, the snapshot's stand in — they are yesterday's
1437 // groups, but whole ones, where grouping the feed's first page locally loses every
1438 // authored row older than it. Once the live reads land they take over; with neither,
1439 // the local grouping is still better than nothing.
1440 const held =
1441 loaded !== null && loaded.environmentKey === environmentKey && loaded.scope === scopeKey
1442 ? loaded.partitions
1443 : undefined;
1444 // The priority reads answer the same question the feed does, so they take the same local
1445 // narrowing: a host that cannot filter for itself would otherwise put rows into Authored
1446 // that the filters above just took out of the feed.
1447 const narrow = (rows: ReadonlyArray<EnvironmentPullRequestEntry> | undefined) =>
1448 rows === undefined || !hasLocalFilters
1449 ? rows
1450 : rows.filter((entry) =>
1451 matchesPullRequestFilters(entry, localFilters, pullRequestEntryViewer(entry, viewers)),
1452 );
1453 const authored = narrow(
1454 partitionsWanted ? (authoredQuery.data?.entries ?? held?.authored) : undefined,
1455 );
1456 const reviewing = narrow(
1457 partitionsWanted ? (reviewingQuery.data?.entries ?? held?.reviewing) : undefined,
1458 );
1459 if (authored === undefined || reviewing === undefined) {
1460 return groupPullRequestsByInvolvement(entries, viewers);
1461 }
1462 return partitionPullRequestsWithPriority(entries, authored, reviewing);
1463 }, [
1464 hasLocalFilters,
1465 baselineEmpty,
1466 localFilters,
1467 authoredQuery.data?.entries,
1468 entries,
1469 environmentKey,
1470 loaded,
1471 partitionsWanted,
1472 reviewingQuery.data?.entries,
1473 scopeKey,
1474 search.involvement,
1475 viewers,
1476 ]);
1477
1478 // Date sorts keep optional line-count reads near the viewport. Size and readiness sorts need
1479 // every loaded count before their order is final. Counts stay cached across both policies.
1480 const entriesByStatsKey = useRef<ReadonlyMap<string, EnvironmentPullRequestEntry>>(new Map());
1481 entriesByStatsKey.current = new Map(
1482 groups.flatMap((group) =>
1483 group.entries.map((entry) => [pullRequestEntryKey(entry), entry] as const),
1484 ),
1485 );
1486 const visibleStatsKeys = useRef({ key: filterKey, values: new Set<string>() });
1487 const [statsByRow, setStatsByRow] = useState<PullRequestDiffStats>(() => new Map());
1488 const statsByRowRef = useRef(statsByRow);
1489 statsByRowRef.current = statsByRow;
1490 const [statsTargetState, setStatsTargetState] = useState<{
1491 readonly key: string;
1492 readonly batches: ReadonlyArray<PullRequestStatsBatch>;
1493 }>({ key: filterKey, batches: [] });
1494 const statsBatches = statsTargetState.key === filterKey ? statsTargetState.batches : [];
1495 const statsTargets = useMemo(
1496 () =>
1497 statsBatches.map(({ environmentId, input }) => ({
1498 environmentId,
1499 input,
1500 })),
1501 [statsBatches],
1502 );
1503 const statsObserver = useRef<IntersectionObserver | null>(null);
1504 const statsRows = useRef(new Set<HTMLDivElement>());
1505 const statsPending = useRef(true);
1506 const statsPolicyRef = useRef(statsPolicy);
1507 statsPolicyRef.current = statsPolicy;
1508 const registerStatsRow = useCallback((node: HTMLDivElement | null) => {
1509 if (node === null || typeof IntersectionObserver === "undefined") return;
1510 statsRows.current.add(node);
1511 statsObserver.current?.observe(node);
1512 return () => {
1513 statsRows.current.delete(node);
1514 statsObserver.current?.unobserve(node);
1515 const key = node.dataset.pullRequestStatsKey;
1516 const visible = visibleStatsKeys.current;
1517 if (
1518 statsPolicyRef.current !== "visible" ||
1519 key === undefined ||
1520 !visible.values.delete(key) ||
1521 statsPending.current
1522 ) {
1523 return;
1524 }
1525 setStatsTargetState((current) => {
1526 if (current.key !== visible.key) return current;
1527 const batches = retainVisiblePullRequestStatsBatches(current.batches, visible.values);
1528 return batches.length === current.batches.length ? current : { key: current.key, batches };
1529 });
1530 };
1531 }, []);
1532 useEffect(() => {
1533 if (statsPolicy !== "eager") return;
1534 setStatsTargetState((current) => {
1535 const batches = current.key === filterKey ? current.batches : [];
1536 const added = pullRequestStatsRequestBatches({
1537 entriesByKey: entriesByStatsKey.current,
1538 candidateKeys: visibleStatsKeys.current.values,
1539 policy: statsPolicy,
1540 activeBatches: batches,
1541 statsByRow: statsByRowRef.current,
1542 });
1543 if (added.length === 0 && current.key === filterKey) return current;
1544 return { key: filterKey, batches: [...batches, ...added] };
1545 });
1546 }, [filterKey, groups, statsPolicy]);
1547 useEffect(() => {
1548 if (statsPolicy !== "visible" || typeof IntersectionObserver === "undefined") return;
1549 visibleStatsKeys.current = { key: filterKey, values: new Set() };
1550 const observer = new IntersectionObserver(
1551 (observed) => {
1552 const visible = visibleStatsKeys.current;
1553 if (visible.key !== filterKey) return;
1554 let changed = false;
1555 const entered = new Set<string>();
1556 for (const item of observed) {
1557 const key = (item.target as HTMLElement).dataset.pullRequestStatsKey;
1558 if (key === undefined) continue;
1559 const wasVisible = visible.values.has(key);
1560 if (item.isIntersecting && entriesByStatsKey.current.has(key)) {
1561 visible.values.add(key);
1562 if (!wasVisible) entered.add(key);
1563 } else {
1564 visible.values.delete(key);
1565 }
1566 changed ||= wasVisible !== visible.values.has(key);
1567 }
1568 if (!changed) return;
1569 setStatsTargetState((current) => {
1570 const batches = current.key === filterKey ? current.batches : [];
1571 const retained = statsPending.current
1572 ? batches
1573 : retainVisiblePullRequestStatsBatches(batches, visible.values);
1574 const added = pullRequestStatsRequestBatches({
1575 entriesByKey: entriesByStatsKey.current,
1576 candidateKeys: entered,
1577 policy: statsPolicy,
1578 activeBatches: batches,
1579 statsByRow: statsByRowRef.current,
1580 });
1581 if (added.length === 0 && retained.length === batches.length) return current;
1582 return { key: filterKey, batches: [...retained, ...added] };
1583 });
1584 },
1585 { root: scrollRef.current, rootMargin: "480px" },
1586 );
1587 statsObserver.current = observer;
1588 for (const row of statsRows.current) observer.observe(row);
1589 return () => {
1590 observer.disconnect();
1591 if (statsObserver.current === observer) statsObserver.current = null;
1592 };
1593 }, [filterKey, statsPolicy]);
1594 const statsQuery = usePullRequestListStats(statsTargets);
1595 statsPending.current = statsQuery.isPending;
1596 useEffect(() => {
1597 if (statsPolicy !== "visible" || statsQuery.isPending) return;
1598 const visible = visibleStatsKeys.current;
1599 setStatsTargetState((current) => {
1600 if (current.key !== filterKey || visible.key !== filterKey) return current;
1601 const batches = retainVisiblePullRequestStatsBatches(current.batches, visible.values);
1602 return batches.length === current.batches.length ? current : { key: current.key, batches };
1603 });
1604 }, [filterKey, statsPolicy, statsQuery.isPending]);
1605 // Adding or removing one row keys a fresh stats query with nothing in it yet, so the counts
1606 // are merged into what is already held rather than rebuilt: every count on screen stays until
1607 // its replacement arrives.
1608 useEffect(() => {
1609 const stats = statsQuery.stats;
1610 if (stats === null) return;
1611 setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats));
1612 }, [statsQuery.stats]);
1613 const displayGroups = useMemo(() => {
1614 // The reader's pending answers go on here, after grouping: the authored and reviewing
1615 // groups are read separately from the feed, and a row closed a moment ago has to leave
1616 // whichever group it was in.
1617 const enriched = groups.map((group) => {
1618 const answered = applyPullRequestOverrides(
1619 group.entries.map((entry) => withDiffStat(entry, statsByRow)),
1620 overrides,
1621 pullRequestEntryKey,
1622 search.state,
1623 );
1624 // A row whose draft flag just changed has to pass the local filters again: a draft
1625 // filter that let it in may not let the new one in.
1626 return {
1627 ...group,
1628 entries:
1629 hasLocalFilters && overrides.size > 0
1630 ? answered.filter(
1631 (entry) =>
1632 !overrides.has(pullRequestEntryKey(entry)) ||
1633 matchesPullRequestFilters(
1634 entry,
1635 localFilters,
1636 pullRequestEntryViewer(entry, viewers),
1637 ),
1638 )
1639 : answered,
1640 };
1641 });
1642 // Searching keeps its relevance order and priority groups unless the reader explicitly asks
1643 // for another sort. The readiness queue is the default browse order, not a way to bury a
1644 // closer text match.
1645 return sortPullRequestGroups(
1646 enriched,
1647 sort,
1648 typedParsed.text,
1649 (entry) =>
1650 entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)),
1651 search.involvement,
1652 );
1653 }, [
1654 groups,
1655 hasLocalFilters,
1656 localFilters,
1657 overrides,
1658 search.involvement,
1659 search.state,
1660 sort,
1661 statsByRow,
1662 typedParsed.text,
1663 viewers,
1664 ]);
1665 /** What is actually on screen once the reader's pending answers are on the rows. */
1666 const shownCount = displayGroups.reduce((count, group) => count + group.entries.length, 0);
1667 // The provider list is the workspace's hosts, not the filtered ones, so switching to a host
1668 // cannot make the switcher that got you there disappear.
1669 const [hosts, setHosts] = useState<PullRequestListResult["providers"]>([]);
1670 useEffect(() => {
1671 // Only from an answer to these filters. Rows carried over from the previous ones bring the
1672 // host summaries of the question they answered, and coming back from one host to all of them
1673 // would take the switcher's other hosts out of it on the strength of the narrowed answer.
1674 if (answered === null) return;
1675 // An unfiltered response is the full set of hosts. A filtered one only seeds the switcher
1676 // when there is nothing to seed it with, which is a link that arrived already scoped.
1677 setHosts((previous) =>
1678 search.host === undefined || previous.length === 0 ? answered.providers : previous,
1679 );
1680 }, [answered, search.host]);
1681 const showProvider = hosts.length > 1;
1682 const hostActionsOf = useMemo(() => pullRequestHostActions(hosts), [hosts]);
1683 const [closeSweepKeys, setCloseSweepKeys] = useState<ReadonlySet<string>>(() => new Set());
1684 const closeSensorRef = useRef<SidebarPointerSensor | null>(null);
1685 const closeSweepRows = useMemo(
1686 () =>
1687 new Map(
1688 displayGroups.flatMap((group) =>
1689 group.entries.map(
1690 (entry) => [pullRequestEntryKey(entry), { entry, groupKey: group.key }] as const,
1691 ),
1692 ),
1693 ),
1694 [displayGroups],
1695 );
1696 const closeSweepRef = useRef({
1697 displayGroups,
1698 closeSweepRows,
1699 closingKeys,
1700 closeBatch,
1701 hostActionsOf,
1702 });
1703 closeSweepRef.current = { displayGroups, closeSweepRows, closingKeys, closeBatch, hostActionsOf };
1704 useEffect(() => () => closeSensorRef.current?.cancel(), [filterKey, search.q, sort]);
1705 useEffect(() => {
1706 const onKeyDown = (event: KeyboardEvent) => {
1707 if (event.key !== "Escape" || closeSensorRef.current === null) return;
1708 event.preventDefault();
1709 closeSensorRef.current.cancel();
1710 };
1711 document.addEventListener("keydown", onKeyDown, true);
1712 return () => document.removeEventListener("keydown", onKeyDown, true);
1713 }, []);
1714 const startCloseSweep = useCallback((entry: EnvironmentPullRequestEntry, event: PointerEvent) => {
1715 closeSensorRef.current?.cancel();
1716 const originKey = pullRequestEntryKey(entry);
1717 const group = closeSweepRef.current.displayGroups.find((candidate) =>
1718 candidate.entries.some((row) => pullRequestEntryKey(row) === originKey),
1719 );
1720 if (!group || closeSweepRef.current.closingKeys.has(originKey)) return;
1721 const orderedKeys = closeSweepRef.current.displayGroups.flatMap((candidate) =>
1722 candidate.entries.map(pullRequestEntryKey),
1723 );
1724 const canClose = (key: string) => {
1725 const row = closeSweepRef.current.closeSweepRows.get(key);
1726 return (
1727 row?.groupKey === group.key &&
1728 row.entry.state === "open" &&
1729 closeSweepRef.current.hostActionsOf(row.entry).has("close") &&
1730 !closeSweepRef.current.closingKeys.has(key) &&
1731 !scrollRef.current?.querySelector(
1732 `[data-pull-request-key="${CSS.escape(key)}"] [data-pull-request-action-pending="true"]`,
1733 )
1734 );
1735 };
1736 let sweptKeys: string[] = [];
1737 let targetKey: string | null = null;
1738 const sweepTo = (key: string) => {
1739 if (key === targetKey) return;
1740 targetKey = key;
1741 sweptKeys = resolveSidebarSweepKeys(orderedKeys, originKey, key, canClose);
1742 setCloseSweepKeys(new Set(sweptKeys));
1743 };
1744 closeSensorRef.current = new SidebarPointerSensor({
1745 active: originKey,
1746 event,
1747 options: {
1748 distance: 6,
1749 onAttach: () => {},
1750 onFinish: () => {
1751 closeSensorRef.current = null;
1752 setCloseSweepKeys(new Set());
1753 },
1754 },
1755 onPending: () => {},
1756 onStart: () => sweepTo(originKey),
1757 onMove: ({ y }) => {
1758 const viewport = scrollRef.current;
1759 if (!viewport) return;
1760 const bounds = viewport.getBoundingClientRect();
1761 const visibleY = Math.min(Math.max(y, bounds.top), bounds.bottom - 1);
1762 let key: string | null = null;
1763 for (const row of viewport.querySelectorAll<HTMLElement>("[data-pull-request-key]")) {
1764 if (key !== null && row.getBoundingClientRect().top > visibleY) break;
1765 key = row.dataset.pullRequestKey ?? null;
1766 }
1767 if (key !== null) sweepTo(key);
1768 },
1769 onEnd: () => {
1770 const batch = sweptKeys.filter(canClose).flatMap((key) => {
1771 const row = closeSweepRef.current.closeSweepRows.get(key);
1772 return row ? [row.entry] : [];
1773 });
1774 void closeSweepRef.current.closeBatch(batch);
1775 },
1776 onCancel: () => {},
1777 onAbort: () => {},
1778 });
1779 }, []);
1780 const heldPullRequestsBySurface = useMemo(
1781 () =>
1782 new Map(
1783 groups.flatMap((group) =>
1784 group.entries.map((entry) => [pullRequestListEntryId(entry), entry] as const),
1785 ),
1786 ),
1787 [groups],
1788 );
1789 const listedPullRequestsBySurface = useMemo(
1790 () =>
1791 new Map(
1792 displayGroups.flatMap((group) =>
1793 group.entries.map((entry) => [pullRequestListEntryId(entry), entry] as const),
1794 ),
1795 ),
1796 [displayGroups],
1797 );
1798
1799 const linkedSelection = useMemo(
1800 () =>
1801 search.repository && search.number && selectedProject
1802 ? {
1803 environmentId: selectedProject.environmentId,
1804 repository: search.repository,
1805 number: search.number,
1806 projectId: selectedProject.id,
1807 ...(selectedHost ? { host: selectedHost } : {}),
1808 }
1809 : null,
1810 [search.number, search.repository, selectedProject, selectedHost],
1811 );
1812 const rightPanelAvailable = selectedPullRequestSurface !== null;
1813 // Maximized like the thread view's panel, with the list collapsing out of the way. Opening a
1814 // guide maximizes the panel for its wider layout; only the reader's own toggle restores it, so
1815 // switching tabs never resizes the page under them.
1816 const [maximized, setMaximized] = useState(false);
1817 const rightPanelMaximized = rightPanelState.isOpen && maximized;
1818 // A closed panel reopens at its own size, as the thread view's does.
1819 if (!rightPanelState.isOpen && maximized) setMaximized(false);
1820 const toggleRightPanelMaximized = () => {
1821 if (rightPanelState.isOpen) setMaximized((value) => !value);
1822 };
1823 const onGuideShown = useCallback(() => setMaximized(true), []);
1824 useEffect(() => {
1825 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;
1826 useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection);
1827 }, [linkedSelection, pullRequestsSupported, rightPanelRef]);
1828
1829 const selected =
1830 rightPanelState.isOpen && activePullRequestSurface !== null
1831 ? {
1832 environmentId: activePullRequestSurface.environmentId,
1833 repository: activePullRequestSurface.repository,
1834 number: activePullRequestSurface.number,
1835 projectId: activePullRequestSurface.projectId as ProjectId,
1836 host:
1837 activePullRequestSurface.host ??
1838 (selectedProject?.repositoryIdentity
1839 ? pullRequestHostOf(
1840 selectedProject.repositoryIdentity,
1841 selectedProject.repositoryIdentity.provider as SourceControlProviderKind,
1842 )
1843 : undefined),
1844 }
1845 : null;
1846
1847 const selectSurfaceInUrl = (surface: PullRequestSurface | null) =>
1848 updateSearch(
1849 surface === null
1850 ? clearedSelection
1851 : {
1852 repository: surface.repository,
1853 number: surface.number,
1854 selectedProjectId: surface.projectId as ProjectId,
1855 selectedHost: surface.host,
1856 ...(surface.environmentId === undefined
1857 ? {}
1858 : { selectedEnvironmentId: surface.environmentId as EnvironmentId }),
1859 },
1860 );
1861
1862 const toggleRightPanel = () => {
1863 if (rightPanelRef === null) return;
1864 if (rightPanelState.isOpen) {
1865 useRightPanelStore.getState().close(rightPanelRef);
1866 updateSearch(clearedSelection);
1867 return;
1868 }
1869 if (selectedPullRequestSurface === null) return;
1870 useRightPanelStore.getState().show(rightPanelRef);
1871 selectSurfaceInUrl(selectedPullRequestSurface);
1872 };
1873
1874 // The workspace's own projects already name their hosts, so the row's shape is known before
1875 // the list is. Only its shape: which hosts can actually be read still comes from the server.
1876 const expectedHosts = useMemo(() => {
1877 const byHost = new Map<string, PullRequestExpectedHost>();
1878 for (const project of projects) {
1879 const kind = project.repositoryIdentity?.provider as SourceControlProviderKind | undefined;
1880 if (kind === undefined) continue;
1881 const host = pullRequestHostOf(project.repositoryIdentity, kind);
1882 if (!byHost.has(host)) byHost.set(host, { host, kind });
1883 }
1884 return [...byHost.values()];
1885 }, [projects]);
1886
1887 /** Reported per project rather than as a count, so the reader can see which one it was. */
1888 const unavailableProjects = useMemo(
1889 () =>
1890 new Map(
1891 listErrors.map(
1892 (error) =>
1893 [
1894 pullRequestProjectKey({ id: error.projectId, environmentId: error.environmentId }),
1895 error.message,
1896 ] as const,
1897 ),
1898 ),
1899 [listErrors],
1900 );
1901
1902 // Stable so the memoized rows can skip re-rendering when the list around them changes.
1903 const selectEntry = useCallback(
1904 (entry: PullRequestRowTarget) => {
1905 // The surface carries the row's own server, which is what its detail reads and acts on.
1906 if (rightPanelRef === null) return;
1907 useRightPanelStore.getState().openPullRequest(rightPanelRef, entry);
1908 updateSearch({
1909 repository: entry.repository,
1910 number: entry.number,
1911 selectedProjectId: entry.projectId,
1912 selectedEnvironmentId: entry.environmentId,
1913 selectedHost: entry.host,
1914 });
1915 },
1916 [rightPanelRef, updateSearch],
1917 );
1918
1919 const searchInput = (
1920 <PullRequestSearchInput
1921 value={search.q ?? ""}
1922 busy={typedQuery.length > 0 && (!querySettled || showingCarried)}
1923 onChange={(query) => updateListScope({ q: query || undefined })}
1924 />
1925 );
1926 const panelToggleControls = (
1927 <PanelLayoutControls
1928 showTerminalControl={false}
1929 showThreadPanelControl={false}
1930 terminalAvailable={false}
1931 terminalOpen={false}
1932 terminalShortcutLabel={null}
1933 threadPanelOpen={false}
1934 threadPanelPresentation="inline"
1935 threadPanelShortcutLabel={null}
1936 onToggleThreadPanel={() => undefined}
1937 rightPanelAvailable={rightPanelAvailable}
1938 rightPanelOpen={rightPanelState.isOpen}
1939 rightPanelShortcutLabel={shortcutLabelForCommand(keybindings, "rightPanel.toggle")}
1940 rightPanelUnavailableLabel="Select a pull request first"
1941 onToggleTerminal={() => undefined}
1942 onToggleRightPanel={toggleRightPanel}
1943 />
1944 );
1945 const openPanelControls = (
1946 <div
1947 // The bare workspace-titlebar-controls inset plus mr-px: the same
1948 // anchor the thread view's controls and the sidebar trigger use, so
1949 // every titlebar cluster in the app sits one shared inset from its
1950 // edge.
1951 className="absolute top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] z-50 mr-px flex h-[var(--workspace-topbar-height)] items-center gap-1 [-webkit-app-region:no-drag]"
1952 data-workspace-titlebar-controls
1953 >
1954 {rightPanelState.isOpen ? (
1955 <RightPanelMaximizeControl
1956 maximized={rightPanelMaximized}
1957 onToggle={toggleRightPanelMaximized}
1958 />
1959 ) : null}
1960 {panelToggleControls}
1961 </div>
1962 );
1963 // The rows carried over from the last filters can also narrow to nothing one step further on,
1964 // where involvement is applied against the viewers of the answer they came from. "Nothing under
1965 // these filters" is a claim, and it is the wrong one to make about a question still in flight,
1966 // so that case waits with the skeletons rather than answering for the hosts. A search says so
1967 // in its own words and is left to.
1968 const carriedToNothing =
1969 showingCarried && listQuery.isPending && shownCount === 0 && typedQuery.length === 0;
1970 const listBody = (
1971 <>
1972 {!capabilityKnown ? (
1973 <PullRequestListGhost rows={7} />
1974 ) : !pullRequestsSupported ? (
1975 <PullRequestsUnavailableState
1976 title="Pull requests unavailable"
1977 error="Update your servers to browse pull requests."
1978 />
1979 ) : firstLoad ? (
1980 <PullRequestListGhost rows={7} />
1981 ) : listQuery.error && shownCount === 0 ? (
1982 <PullRequestsUnavailableState
1983 error={listQuery.error}
1984 refreshing={listQuery.isPending}
1985 onRetry={() => listQuery.refresh()}
1986 />
1987 ) : carriedToNothing ? (
1988 <PullRequestListGhost rows={7} />
1989 ) : shownCount === 0 ? (
1990 <PullRequestListEmptyState
1991 hasProjects={!projectsKnown || projects.length > 0}
1992 refreshing={refreshing}
1993 onRefresh={() => void refreshFromHost()}
1994 query={typedQuery}
1995 filtered={
1996 menuFiltered ||
1997 search.state !== "open" ||
1998 search.involvement !== "all" ||
1999 scopedProjectId !== undefined ||
2000 search.host !== undefined
2001 }
2002 searching={typedQuery.length > 0 && (!querySettled || showingCarried)}
2003 canLoadMore={listData?.truncated === true && (canContinue || pageSize < MAX_PAGE_SIZE)}
2004 loadingMore={loadingMore}
2005 onClearQuery={() => updateListScope({ q: undefined })}
2006 onLoadMore={loadMore}
2007 />
2008 ) : (
2009 <div className={cn("space-y-3", closeSweepKeys.size > 0 && "**:pointer-events-none")}>
2010 {displayGroups.map((group) => (
2011 <div key={group.key} className="space-y-0.5">
2012 {group.label ? <PullRequestGroupHeader group={group} /> : null}
2013 {group.entries.map((entry) => {
2014 const entryKey = pullRequestEntryKey(entry);
2015 return (
2016 <PullRequestRow
2017 key={entryKey}
2018 statsKey={entryKey}
2019 statsRef={registerStatsRow}
2020 entry={entry}
2021 showProjectTitle
2022 showProvider={showProvider}
2023 {...(capableEnvironments.length > 1 &&
2024 environmentLabels.get(entry.environmentId) !== undefined
2025 ? { environmentLabel: environmentLabels.get(entry.environmentId)! }
2026 : {})}
2027 // Ten is the floor the ranking gives a row whose own fields say nothing
2028 // about the search: the host matched something this row cannot show.
2029 matchedElsewhere={
2030 typedParsed.text.length > 0 &&
2031 scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE
2032 }
2033 selected={
2034 selected?.environmentId === entry.environmentId &&
2035 selected.repository === entry.repository &&
2036 selected.host?.toLowerCase() === entry.host.toLowerCase() &&
2037 selected.number === entry.number
2038 }
2039 onSelect={selectEntry}
2040 speedMode={speedMode}
2041 hostActions={hostActionsOf(entry)}
2042 onActed={onSpeedAction}
2043 closing={closingKeys.has(entryKey)}
2044 sweeping={closeSweepKeys.has(entryKey)}
2045 onCloseSweepStart={startCloseSweep}
2046 />
2047 );
2048 })}
2049 </div>
2050 ))}
2051 </div>
2052 )}
2053
2054 {listQuery.error && shownCount > 0 ? (
2055 <div className="flex items-center justify-between gap-3 rounded-lg border border-warning/30 bg-warning-surface px-3 py-2 text-xs">
2056 <span>{listQuery.error} Showing the last pull requests loaded.</span>
2057 <Button size="xs" variant="outline" onClick={() => listQuery.refresh()}>
2058 Retry
2059 </Button>
2060 </div>
2061 ) : null}
2062 {listData?.truncated && entries.length > 0 ? (
2063 <div className="flex justify-center py-3 text-xs text-muted-foreground">
2064 {loadingMore ? (
2065 <span className="flex items-center gap-2">
2066 <Spinner aria-hidden size="sm" />
2067 {sentCursors === null ? "Updating pull requests" : "Loading more"}
2068 </span>
2069 ) : canContinue || pageSize < MAX_PAGE_SIZE ? (
2070 <Button
2071 size="sm"
2072 variant="outline"
2073 onClick={loadMore}
2074 disabled={listQuery.isPending || showingCarried}
2075 >
2076 Load more pull requests
2077 </Button>
2078 ) : (
2079 <span>Narrow your search to find more pull requests.</span>
2080 )}
2081 </div>
2082 ) : null}
2083 </>
2084 );
2085 // The same names and glyphs the host pills wear, so the compact menu and the pills read as
2086 // one control: "GitHub" with its mark, never the bare hostname — unless two installs of one
2087 // kind force the hostname to tell them apart.
2088 const hostEntries = hosts.length > 0 ? hosts : expectedHosts;
2089 const hostMenuOptions: ReadonlyArray<PullRequestFilterOption<string>> = [
2090 { value: "", label: "All", Icon: Plug2Icon },
2091 ...hostEntries.map((entry) => {
2092 // `expectedHosts` stands in before the server has answered, and nothing is known to be
2093 // unreadable yet; once the summaries arrive they carry whether each one could be read.
2094 const summary = hosts.find((host) => host.host === entry.host);
2095 return {
2096 value: entry.host,
2097 label: pullRequestHostLabel(hostEntries, entry),
2098 Icon: getSourceControlPresentationForKind(entry.kind).Icon,
2099 ...(summary === undefined || summary.configured
2100 ? {}
2101 : { unavailable: summary.detail ?? "This host could not be read." }),
2102 };
2103 }),
2104 ];
2105 // The same shape the host pills take, so the two groups read as one control. Each server
2106 // wears the machine it runs on.
2107 const serverMenuOptions: ReadonlyArray<PullRequestFilterOption<string>> = [
2108 { value: "", label: "All servers", Icon: LayersIcon },
2109 ...capableEnvironments.map((environment) => ({
2110 value: environment.environmentId,
2111 label: environment.label,
2112 Icon: environmentMachineIcon(resolveEnvironmentMachineKind(environment.serverConfig)),
2113 })),
2114 ];
2115 const sortMenu = (
2116 <CompactFilterMenu
2117 label="Sort pull requests"
2118 triggerIcon={<ArrowDownUpIcon aria-hidden className="size-4" />}
2119 triggerLabel="Sort"
2120 outlined
2121 value={sort}
2122 options={SORT_OPTIONS}
2123 onChange={(next) => updateListScope({ sort: next })}
2124 />
2125 );
2126 const filtersMenu = (
2127 <PullRequestFiltersMenuInSidebarOrder
2128 onOpenChange={setFiltersOpen}
2129 state={search.state}
2130 stateOptions={STATE_TABS}
2131 onState={(state) => updateListScope({ state })}
2132 involvement={search.involvement}
2133 involvementOptions={INVOLVEMENT_TABS}
2134 onInvolvement={(involvement) => updateListScope({ involvement })}
2135 filters={menuFilters}
2136 onFilters={(next) =>
2137 updateListScope({
2138 draft: next.draft,
2139 review: next.review,
2140 checks: next.checks,
2141 author: next.author,
2142 labels: next.labels?.flatMap((group) => group),
2143 })
2144 }
2145 authorOptions={facets.authors}
2146 labelOptions={facets.labels}
2147 host={search.host}
2148 hostOptions={hostMenuOptions}
2149 onHost={(host) => updateListScope({ host })}
2150 server={scopedEnvironmentId ?? undefined}
2151 serverOptions={serverMenuOptions}
2152 // Narrowing to one server drops a project scope belonging to another, which would
2153 // otherwise narrow the list to nothing with no visible filter to explain it.
2154 onServer={(server) => updateListScope({ environmentId: server, projectId: undefined })}
2155 projects={scopedProjects}
2156 projectId={scopedProjectId}
2157 projectEnvironmentId={scopedProject?.environmentId}
2158 unavailable={unavailableProjects}
2159 // The environment comes along with the project it belongs to, so a duplicate id on
2160 // another server never gets narrowed to by mistake; picking "All projects" leaves the
2161 // server scope as it was rather than clearing it.
2162 onProject={(projectId, environmentId) =>
2163 updateListScope(environmentId === undefined ? { projectId } : { projectId, environmentId })
2164 }
2165 />
2166 );
2167 const columnProps = {
2168 refreshing,
2169 onRefresh: () => void refreshFromHost(),
2170 searchValue: search.q ?? "",
2171 involvement: search.involvement,
2172 state: search.state,
2173 host: search.host,
2174 hostMenuOptions,
2175 onInvolvement: (involvement: PullRequestInvolvement) => updateListScope({ involvement }),
2176 onState: (state: PullRequestListState) => updateListScope({ state }),
2177 onHost: (host: string | undefined) => updateListScope({ host }),
2178 searchInput,
2179 sortMenu,
2180 filtersMenu,
2181 mergebotControl: environments
2182 .filter(
2183 (environment) =>
2184 environment.serverConfig?.mergebot &&
2185 (scopedEnvironmentId === null || environment.environmentId === scopedEnvironmentId),
2186 )
2187 .map((environment) => (
2188 <Button
2189 key={environment.environmentId}
2190 variant="outline"
2191 size="sm"
2192 onClick={() =>
2193 void navigate({
2194 search: { involvement: "all", state: "open", mergebot: environment.environmentId },
2195 })
2196 }
2197 >
2198 {environments.filter((entry) => entry.serverConfig?.mergebot).length > 1
2199 ? `Mergebot · ${environment.label}`
2200 : "Mergebot"}
2201 </Button>
2202 )),
2203 rightPanelControl:
2204 // Footprint reserve while the panel is closed: the toggle itself stays
2205 // mounted at the fixed titlebar inset in both states so it cannot move
2206 // on toggle, and this spacer keeps refresh from sliding underneath it
2207 // (sized per header padding so refresh ends a normal gap short of it).
2208 !pullRequestsSupported ? null : (
2209 <span
2210 aria-hidden
2211 className={cn(
2212 "shrink-0",
2213 rightPanelState.isOpen ? "-ml-3 w-0" : "w-7 sm:w-5",
2214 panelAnimationsActive && "transition-[width,margin] ease-out",
2215 )}
2216 style={
2217 panelAnimationsActive
2218 ? { transitionDuration: `${panelAnimationDurationMs}ms` }
2219 : undefined
2220 }
2221 />
2222 ),
2223 titlebarControls:
2224 // While the panel is closed the strip lives inside the header: a no-drag
2225 // descendant beats the header's desktop drag-region, where a floating
2226 // sibling loses (app-region hit-testing ignores z-index). While the
2227 // floating strip crosses the header during motion, the narrow extension
2228 // keeps that overlap non-draggable without moving the toggle.
2229 pullRequestsSupported ? (
2230 rightPanelPresent ? (
2231 <span
2232 aria-hidden
2233 className="pointer-events-none absolute inset-y-0 left-full w-7 [-webkit-app-region:no-drag]"
2234 />
2235 ) : (
2236 openPanelControls
2237 )
2238 ) : null,
2239 rightPanelOpen: rightPanelState.isOpen,
2240 maximizedAway: rightPanelMaximized,
2241 listBody,
2242 scrollRef,
2243 };
2244
2245 const activateSurface = (surface: PullRequestSurface) => {
2246 if (rightPanelRef === null) return;
2247 useRightPanelStore.getState().activateSurface(rightPanelRef, surface.id);
2248 selectSurfaceInUrl(surface);
2249 };
2250 const closeSurfaces = async (surfaces: readonly RightPanelSurface[]) => {
2251 if (rightPanelRef === null) return;
2252 for (const surface of surfaces)
2253 useRightPanelStore.getState().closeSurface(rightPanelRef, surface.id);
2254 const closeRevision = useRightPanelStore.getState().getUserActionRevision(rightPanelRef);
2255 const next = selectActiveRightPanelSurface(
2256 useRightPanelStore.getState().byThreadKey,
2257 rightPanelRef,
2258 );
2259 selectSurfaceInUrl(next?.kind === "pull-request" ? next : null);
2260
2261 for (const surface of surfaces) {
2262 if (surface.kind !== "pull-request") continue;
2263 const environmentId =
2264 (surface.environmentId as EnvironmentId | undefined) ?? panelEnvironmentId;
2265 if (!environmentId) continue;
2266 const configuration = environments.find(
2267 (environment) => environment.environmentId === environmentId,
2268 )?.serverConfig?.mergebot;
2269 if (!mergebotPullRequestUrl(configuration, surface)) continue;
2270 const owner = scopeThreadRef(
2271 environmentId,
2272 ThreadId.make(mergebotPullRequestBrowserThreadId(surface)),
2273 );
2274 const result = await closeMergebotPreview({
2275 environmentId,
2276 input: { threadId: owner.threadId },
2277 });
2278 if (result._tag === "Failure") {
2279 const panels = useRightPanelStore.getState();
2280 if (panels.openProactive(rightPanelRef, surface, closeRevision))
2281 selectSurfaceInUrl(surface);
2282 } else {
2283 clearThreadPreviewState(owner);
2284 }
2285 }
2286 };
2287 const closeSurface = (surface: PullRequestSurface) => void closeSurfaces([surface]);
2288 const closeOtherSurfaces = (surface: PullRequestSurface) =>
2289 void closeSurfaces(rightPanelState.surfaces.filter((entry) => entry.id !== surface.id));
2290 const closeSurfacesToRight = (surface: PullRequestSurface) => {
2291 const index = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id);
2292 if (index >= 0) void closeSurfaces(rightPanelState.surfaces.slice(index + 1));
2293 };
2294 const closeAllSurfaces = () => void closeSurfaces(rightPanelState.surfaces);
2295
2296 // This page has no ChatView, so it handles the shared panel shortcuts itself.
2297 const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => {
2298 if (!openPanelPullRequestUrl) return;
2299 event.preventDefault();
2300 event.stopPropagation();
2301 if (event.repeat) return;
2302 const url = openPanelPullRequestUrl;
2303 void writeTextToClipboard(url, "pull request link").then(
2304 (didCopy) => {
2305 if (didCopy)
2306 toastManager.add({ type: "success", title: "PR link copied", description: url });
2307 },
2308 (error) => {
2309 toastManager.add({
2310 type: "error",
2311 title: "Failed to copy PR link",
2312 description: error instanceof Error ? error.message : "An error occurred.",
2313 });
2314 },
2315 );
2316 });
2317 const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => {
2318 if (activePullRequestSurface === null) return;
2319 event.preventDefault();
2320 event.stopPropagation();
2321 if (!event.repeat) closeSurface(activePullRequestSurface);
2322 });
2323 const toggleRightPanelFromShortcut = useEffectEvent((event: KeyboardEvent) => {
2324 if (!rightPanelAvailable) return;
2325 event.preventDefault();
2326 event.stopPropagation();
2327 if (!event.repeat) toggleRightPanel();
2328 });
2329 const toggleRightPanelMaximizedFromShortcut = useEffectEvent((event: KeyboardEvent) => {
2330 if (!rightPanelState.isOpen) return;
2331 event.preventDefault();
2332 event.stopPropagation();
2333 if (!event.repeat) toggleRightPanelMaximized();
2334 });
2335 useEffect(() => {
2336 const onKeyDown = (event: KeyboardEvent) => {
2337 if (event.defaultPrevented || isCommandPaletteOpen()) return;
2338 const command = resolveShortcutCommand(event, keybindings, {
2339 context: getShortcutContext(),
2340 });
2341 if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event);
2342 if (command === "rightPanel.toggle") toggleRightPanelFromShortcut(event);
2343 if (command === "rightPanel.toggleMaximized") toggleRightPanelMaximizedFromShortcut(event);
2344 if (command === "thread.copyReference") copyPullRequestFromShortcut(event);
2345 };
2346 // Let panel shortcuts consume Escape before page navigation at window.
2347 document.addEventListener("keydown", onKeyDown);
2348 return () => document.removeEventListener("keydown", onKeyDown);
2349 }, [keybindings]);
2350
2351 return (
2352 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">
2353 <div className="relative flex min-h-0 flex-1">
2354 {pullRequestsSupported && rightPanelPresent ? openPanelControls : null}
2355 <PullRequestsColumn {...columnProps} />
2356
2357 {rightPanelPresent && renderedPullRequestSurface && panelEnvironmentId !== null ? (
2358 <RightPanelTabs
2359 mode="inline"
2360 open={rightPanelState.isOpen}
2361 maximized={rightPanelMaximized}
2362 keybindings={keybindings}
2363 getShortcutContext={getShortcutContext}
2364 widthStorageKey="t3code:pull-request-panel-width"
2365 // Default to roughly half the viewport: the PR list needs more
2366 // room than a chat, so the 540px chat-preview default squashes
2367 // it. SSR has no window, so fall back to a reasonable width.
2368 defaultWidth={typeof window === "undefined" ? 640 : Math.floor(window.innerWidth / 2)}
2369 surfaces={renderedRightPanelSurfaces}
2370 environmentId={panelEnvironmentId}
2371 activeSurfaceId={renderedPullRequestSurface.id}
2372 pendingSurfaceIds={EMPTY_PENDING_SURFACES}
2373 previewSessions={EMPTY_PREVIEW_SESSIONS}
2374 desktopByTabId={EMPTY_PREVIEW_DESKTOP_STATE}
2375 terminalLabelsById={EMPTY_TERMINAL_LABELS}
2376 onActivate={(surface) => {
2377 if (surface.kind === "pull-request") activateSurface(surface);
2378 }}
2379 onCloseSurface={(surface) => {
2380 if (surface.kind === "pull-request") closeSurface(surface);
2381 }}
2382 onCloseOtherSurfaces={(surface) => {
2383 if (surface.kind === "pull-request") closeOtherSurfaces(surface);
2384 }}
2385 onCloseSurfacesToRight={(surface) => {
2386 if (surface.kind === "pull-request") closeSurfacesToRight(surface);
2387 }}
2388 onCloseAllSurfaces={closeAllSurfaces}
2389 onMoveSurface={(surfaceId, toIndex) => {
2390 if (rightPanelRef !== null)
2391 useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex);
2392 }}
2393 onCopyFilePath={() => undefined}
2394 onAddBrowser={() => undefined}
2395 onAddBrowserInProfile={() => undefined}
2396 onAddTerminal={() => undefined}
2397 onAddDiff={() => undefined}
2398 onAddFiles={() => undefined}
2399 onAddPullRequest={() => undefined}
2400 onAddPullRequests={() => undefined}
2401 onAddDevice={() => undefined}
2402 browserAvailable={false}
2403 terminalAvailable={false}
2404 diffAvailable={false}
2405 filesAvailable={false}
2406 pullRequestAvailable={false}
2407 pullRequestsAvailable={false}
2408 deviceAvailable={false}
2409 pullRequestStatusSeeds={listedPullRequestTabStatuses}
2410 >
2411 <PullRequestDetailPanel
2412 getShortcutContext={getShortcutContext}
2413 shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id}
2414 key={renderedPullRequestSurface.id}
2415 environmentId={panelEnvironmentId}
2416 onMergebotReady={() =>
2417 rightPanelRef !== null &&
2418 selectThreadRightPanelState(
2419 useRightPanelStore.getState().byThreadKey,
2420 rightPanelRef,
2421 ).surfaces.some((surface) => surface.id === renderedPullRequestSurface.id)
2422 }
2423 onGuideShown={onGuideShown}
2424 onSelectPullRequest={(reference) => {
2425 if (rightPanelRef === null) return;
2426 useRightPanelStore.getState().openPullRequest(rightPanelRef, {
2427 projectId: reference.projectId,
2428 repository: reference.repository,
2429 number: reference.number,
2430 ...(reference.host ? { host: reference.host } : {}),
2431 environmentId: panelEnvironmentId,
2432 });
2433 updateSearch({
2434 repository: reference.repository,
2435 number: reference.number,
2436 selectedHost: reference.host,
2437 selectedProjectId: reference.projectId,
2438 selectedEnvironmentId: panelEnvironmentId,
2439 });
2440 }}
2441 reference={{
2442 projectId: renderedPullRequestSurface.projectId as ProjectId,
2443 repository: renderedPullRequestSurface.repository,
2444 number: renderedPullRequestSurface.number,
2445 ...(renderedPullRequestSurface.host
2446 ? { host: renderedPullRequestSurface.host }
2447 : {}),
2448 }}
2449 listEntry={
2450 listedPullRequestsBySurface.get(
2451 pullRequestListEntryId(renderedPullRequestSurface),
2452 ) ?? null
2453 }
2454 refreshToken={detailRefreshToken}
2455 // Host actions can change both readiness and diff size, so refresh the counts
2456 // alongside the list. The panel already refreshes itself after each action.
2457 onActed={(action, phase = "done") => {
2458 // An action that only moves a row's state is written onto the row as it is
2459 // sent, and taken back if the host refuses; the host invalidates its caches,
2460 // so the next scheduled read confirms it. The rest change what the counts and
2461 // checks say, and those need the reads once they are done.
2462 // From every row held, not the ones on screen: a pull request closed from an
2463 // open list has left the screen, and reopening it has to find it anyway.
2464 const acted = heldPullRequestsBySurface.get(
2465 pullRequestListEntryId(renderedPullRequestSurface),
2466 );
2467 const stateOnly =
2468 action !== undefined &&
2469 acted !== undefined &&
2470 pullRequestOverrideAfterAction(acted, action, new Date(), 0) !== null;
2471 if (stateOnly) {
2472 const key = pullRequestEntryKey(acted);
2473 // A merge is written on once the host has done it, since a host that only
2474 // queues one leaves the pull request open; the rest go on as they are sent.
2475 if (phase === "sent" && action !== "merge") {
2476 detailOverrideTokens.current.set(key, overrideEntry(acted, action));
2477 }
2478 // A merge wrote nothing on the way out, so its failure has nothing to take
2479 // back; an earlier action's note on the same row is left standing.
2480 if (phase === "failed" && action !== "merge") {
2481 revertOverride(key, detailOverrideTokens.current.get(key) ?? null);
2482 }
2483 if (phase !== "sent") detailOverrideTokens.current.delete(key);
2484 if (phase === "done" && action === "merge") {
2485 overrideEntry(acted, action);
2486 refreshListAndStats(undefined, panelEnvironmentId);
2487 }
2488 return;
2489 }
2490 if (phase === "done") refreshListAndStats(undefined, panelEnvironmentId);
2491 }}
2492 />
2493 </RightPanelTabs>
2494 ) : null}
2495 </div>
2496 </SidebarInset>
2497 );
2498}
2499
2500/** A compact stand-in for one pill group when the header is narrow. */
2501/** Lists the projects in the sidebar's order; it re-renders with thread activity, so the page does not. */
2502function PullRequestFiltersMenuInSidebarOrder(
2503 props: ComponentProps<typeof PullRequestFiltersMenu>,
2504) {
2505 const sidebarProjectGroups = useSidebarProjectGroups();
2506 const projects = useMemo(
2507 () => sortProjectsByGroupOrder(props.projects, sidebarProjectGroups),
2508 [props.projects, sidebarProjectGroups],
2509 );
2510 return <PullRequestFiltersMenu {...props} projects={projects} />;
2511}
2512
2513function CompactFilterMenu<Value extends string>({
2514 label,
2515 triggerIcon,
2516 triggerLabel,
2517 outlined = false,
2518 iconOnly = false,
2519 value,
2520 options,
2521 onChange,
2522 className,
2523}: {
2524 label: string;
2525 triggerIcon?: ReactNode;
2526 triggerLabel?: string;
2527 outlined?: boolean;
2528 iconOnly?: boolean;
2529 value: Value;
2530 options: ReadonlyArray<PullRequestFilterOption<Value>>;
2531 onChange: (value: Value) => void;
2532 className?: string;
2533}) {
2534 const current = options.find((option) => option.value === value) ?? options[0];
2535 if (!current) return null;
2536 return (
2537 <Menu>
2538 <MenuTrigger
2539 aria-label={triggerLabel || iconOnly ? `${label}: ${current.label}` : label}
2540 title={iconOnly ? `${label}: ${current.label}` : undefined}
2541 render={
2542 outlined ? (
2543 <Button variant="outline" size={iconOnly ? "icon" : "default"} />
2544 ) : (
2545 <Button variant="ghost-muted" size="sm" />
2546 )
2547 }
2548 className={cn("min-w-0", className)}
2549 >
2550 {iconOnly ? (
2551 <current.Icon aria-hidden className="size-4" />
2552 ) : triggerLabel ? (
2553 <>
2554 {triggerIcon}
2555 <span>{triggerLabel}</span>
2556 </>
2557 ) : (
2558 <>
2559 <span className="truncate">{current.label}</span>
2560 <ChevronDownIcon aria-hidden className="size-3 shrink-0 text-muted-foreground/70" />
2561 </>
2562 )}
2563 </MenuTrigger>
2564 <MenuPopup align="start" side="bottom">
2565 <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>
2566 {options.map((option) => {
2567 const item = (
2568 <MenuRadioItem
2569 key={option.value}
2570 value={option.value}
2571 disabled={option.unavailable !== undefined}
2572 className="data-disabled:pointer-events-auto"
2573 >
2574 <span className="flex min-w-0 items-center gap-2">
2575 <PullRequestFilterOptionIcon option={option} />
2576 {option.label}
2577 </span>
2578 </MenuRadioItem>
2579 );
2580 return option.unavailable === undefined ? (
2581 item
2582 ) : (
2583 <Tooltip key={option.value}>
2584 <TooltipTrigger render={item} />
2585 <TooltipPopup side="right">{option.unavailable}</TooltipPopup>
2586 </Tooltip>
2587 );
2588 })}
2589 </MenuRadioGroup>
2590 </MenuPopup>
2591 </Menu>
2592 );
2593}
2594
2595/**
2596 * The search, folded to an icon until asked for. Opening moves focus into the input — the
2597 * whole point of pressing it is to type. It stays open while it holds a query, so an active
2598 * search is never invisible; empty and blurred, it folds back.
2599 */
2600function ExpandableSearch({
2601 searchInput,
2602 searchValue,
2603 open,
2604 onOpenChange,
2605 focusToken,
2606 onFocusWithin,
2607}: {
2608 searchInput: ReactNode;
2609 searchValue: string;
2610 open: boolean;
2611 onOpenChange: (open: boolean) => void;
2612 /** Bumped to pull focus into the input while it is already showing — the Mod+F path. */
2613 focusToken: number;
2614 /**
2615 * Focus entering and leaving the expanded input. An unmount fires no blur, which is the
2616 * point: whoever unmounted this can still see the reader was mid-typing and move the
2617 * focus somewhere that continues the sentence.
2618 */
2619 onFocusWithin?: (focused: boolean) => void;
2620}) {
2621 const containerRef = useRef<HTMLDivElement | null>(null);
2622 useEffect(() => {
2623 if (!open) return;
2624 containerRef.current?.querySelector("input")?.focus();
2625 }, [open]);
2626 const appliedFocusToken = useRef(focusToken);
2627 useEffect(() => {
2628 if (appliedFocusToken.current === focusToken) return;
2629 appliedFocusToken.current = focusToken;
2630 const input = containerRef.current?.querySelector("input");
2631 input?.focus();
2632 input?.select();
2633 }, [focusToken]);
2634 if (open || searchValue.length > 0) {
2635 return (
2636 <div
2637 ref={containerRef}
2638 className="w-56 min-w-24 shrink"
2639 onFocus={() => onFocusWithin?.(true)}
2640 onBlur={() => {
2641 onFocusWithin?.(false);
2642 if (searchValue.length === 0) onOpenChange(false);
2643 }}
2644 >
2645 {searchInput}
2646 </div>
2647 );
2648 }
2649 return (
2650 <Button
2651 size="icon-sm"
2652 variant="ghost"
2653 aria-label="Search pull requests"
2654 onClick={() => onOpenChange(true)}
2655 >
2656 <SearchIcon className="size-4" />
2657 </Button>
2658 );
2659}
2660
2661/**
2662 * The pull request list column. The full controls live at the top of the scroll flow; once
2663 * they scroll away, the title transforms into the scope itself — "Pull Requests / Open ▾
2664 * Authored ▾" — where each segment is the menu for that filter, and a folded search sits on
2665 * the right. Scrolled back up, the topbar returns to the plain title. The topbar is the
2666 * window drag region throughout; its interactive children opt out through the `.drag-region`
2667 * descendant rules.
2668 */
2669function PullRequestsColumn({
2670 refreshing,
2671 onRefresh,
2672 searchValue,
2673 involvement,
2674 state,
2675 host,
2676 hostMenuOptions,
2677 onInvolvement,
2678 onState,
2679 onHost,
2680 searchInput,
2681 sortMenu,
2682 filtersMenu,
2683 mergebotControl,
2684 rightPanelControl,
2685 titlebarControls,
2686 rightPanelOpen,
2687 maximizedAway,
2688 listBody,
2689 scrollRef,
2690}: {
2691 refreshing: boolean;
2692 onRefresh: () => void;
2693 searchValue: string;
2694 involvement: PullRequestInvolvement;
2695 state: PullRequestListState;
2696 host: string | undefined;
2697 hostMenuOptions: ReadonlyArray<PullRequestFilterOption<string>>;
2698 onInvolvement: (involvement: PullRequestInvolvement) => void;
2699 onState: (state: PullRequestListState) => void;
2700 onHost: (host: string | undefined) => void;
2701 searchInput: ReactNode;
2702 sortMenu: ReactNode;
2703 filtersMenu: ReactNode;
2704 mergebotControl: ReactNode;
2705 rightPanelControl: ReactNode;
2706 titlebarControls: ReactNode;
2707 rightPanelOpen: boolean;
2708 /** The panel beside the list is maximized, so the list gives up its width. */
2709 maximizedAway: boolean;
2710 listBody: ReactNode;
2711 scrollRef: RefObject<HTMLDivElement | null>;
2712}) {
2713 const markerRef = useRef<HTMLDivElement | null>(null);
2714 const [condensed, setCondensed] = useState(false);
2715 useEffect(() => {
2716 const marker = markerRef.current;
2717 if (!marker) return;
2718 const observer = new IntersectionObserver(
2719 ([entry]) => setCondensed(entry ? !entry.isIntersecting : false),
2720 { root: scrollRef.current },
2721 );
2722 observer.observe(marker);
2723 return () => observer.disconnect();
2724 }, []);
2725 // Typing into the topbar search narrows the list, and a short enough list un-scrolls the
2726 // page — which dissolves the condensed topbar and unmounts the very input being typed in.
2727 // The two inputs are one search to the reader, so the focus follows the value into the
2728 // in-flow bar, caret at the end, and the sentence continues.
2729 const topbarSearchFocusedRef = useRef(false);
2730 const inFlowSearchRef = useRef<HTMLDivElement | null>(null);
2731 const [searchOpen, setSearchOpen] = useState(false);
2732 const [searchFocusToken, setSearchFocusToken] = useState(0);
2733 const searchExpanded = searchOpen || searchValue.length > 0;
2734 // Mod+F belongs to this page's own search: the desktop shell binds no find-in-page, so the
2735 // shortcut would otherwise do nothing. Condensed, it unfolds the topbar search; at the top,
2736 // it focuses the in-flow bar and selects the query the way a find field would.
2737 useEffect(() => {
2738 const onKeyDown = (event: KeyboardEvent) => {
2739 if (event.defaultPrevented) return;
2740 if (event.key.toLowerCase() !== "f" || !(event.metaKey || event.ctrlKey)) return;
2741 if (event.altKey || event.shiftKey) return;
2742 event.preventDefault();
2743 if (condensed) {
2744 setSearchOpen(true);
2745 setSearchFocusToken((token) => token + 1);
2746 return;
2747 }
2748 const input = inFlowSearchRef.current?.querySelector("input");
2749 input?.focus();
2750 input?.select();
2751 };
2752 window.addEventListener("keydown", onKeyDown);
2753 return () => window.removeEventListener("keydown", onKeyDown);
2754 }, [condensed]);
2755 useEffect(() => {
2756 if (condensed) return;
2757 // The fold-out is gone from the chrome; forgetting it open keeps the next condensing
2758 // from starting with an empty expanded search nobody asked for.
2759 setSearchOpen(false);
2760 if (!topbarSearchFocusedRef.current) return;
2761 topbarSearchFocusedRef.current = false;
2762 const input = inFlowSearchRef.current?.querySelector("input");
2763 if (!input) return;
2764 input.focus();
2765 input.setSelectionRange(input.value.length, input.value.length);
2766 }, [condensed]);
2767
2768 return (
2769 // Painted flat like the chat column: the inset underneath carries the chrome grain, and a
2770 // content surface that lets it show reads as a different background than every thread.
2771 <div
2772 className={cn(
2773 "@container/pr-list flex min-h-0 min-w-0 flex-col overflow-x-hidden bg-background",
2774 maximizedAway ? "w-0 flex-none" : "flex-1",
2775 )}
2776 >
2777 {/* A closed right panel leaves this column full-width, so the shared header
2778 reserves native window controls and hosts the controls strip itself: on
2779 desktop the header is a drag-region, and only a no-drag descendant wins
2780 clicks from it - a floating sibling loses to app-region hit-testing no
2781 matter its z-index. While the panel is open, the strip mounts back at
2782 the route level, whose box spans the panel too, so the toggle keeps one
2783 fixed top-right anchor. */}
2784 <WorkspacePageHeader
2785 electron={isElectron}
2786 reserveNativeControls={!rightPanelOpen}
2787 className="relative bg-background"
2788 >
2789 {titlebarControls}
2790 {condensed ? (
2791 <WorkspaceBreadcrumb ariaLabel="Pull request scope" className="overflow-hidden">
2792 {/* An expanded search owns the scarce horizontal space. The page title stays
2793 available to readers while the live filters remain available in both states. */}
2794 <WorkspaceBreadcrumbItem current className={cn(searchExpanded && "sr-only")}>
2795 <h1 className="truncate">Pull Requests</h1>
2796 </WorkspaceBreadcrumbItem>
2797 {searchExpanded ? null : <WorkspaceBreadcrumbSeparator />}
2798 <WorkspaceBreadcrumbItem className="shrink gap-1.5">
2799 <CompactFilterMenu
2800 label="Filter by state"
2801 value={state}
2802 options={STATE_TABS}
2803 onChange={onState}
2804 className="shrink-0"
2805 />
2806 <CompactFilterMenu
2807 label="Filter by involvement"
2808 value={involvement}
2809 options={INVOLVEMENT_TABS}
2810 onChange={onInvolvement}
2811 />
2812 {hostMenuOptions.length > 2 ? (
2813 <CompactFilterMenu
2814 label="Filter by host"
2815 value={host ?? ""}
2816 options={hostMenuOptions}
2817 onChange={(next) => onHost(next === "" ? undefined : next)}
2818 />
2819 ) : null}
2820 </WorkspaceBreadcrumbItem>
2821 </WorkspaceBreadcrumb>
2822 ) : (
2823 <WorkspaceBreadcrumb ariaLabel="Pull requests breadcrumb">
2824 <WorkspaceBreadcrumbItem current>
2825 <h1 className="truncate">Pull Requests</h1>
2826 </WorkspaceBreadcrumbItem>
2827 </WorkspaceBreadcrumb>
2828 )}
2829 <div className="min-w-0 flex-1" />
2830 {condensed ? (
2831 <div className="flex shrink items-center gap-1.5">
2832 <ExpandableSearch
2833 searchInput={searchInput}
2834 searchValue={searchValue}
2835 open={searchOpen}
2836 onOpenChange={setSearchOpen}
2837 focusToken={searchFocusToken}
2838 onFocusWithin={(focused) => {
2839 topbarSearchFocusedRef.current = focused;
2840 }}
2841 />
2842 <PullRequestRefreshControl compact refreshing={refreshing} onRefresh={onRefresh} />
2843 </div>
2844 ) : null}
2845 {rightPanelControl}
2846 </WorkspacePageHeader>
2847
2848 <div
2849 ref={scrollRef}
2850 className="topbar-scroll-fade scrollbar-gutter-both min-h-0 flex-1 overflow-y-auto"
2851 >
2852 {/* The top padding is the shared fade band's height, the same pairing the
2853 settings page makes: at rest the controls sit fully below the mask, and only
2854 content actually passing under the chrome fades. */}
2855 <WorkspacePageContainer width="expanded" className="min-h-full gap-4">
2856 <div className="flex flex-col gap-3">
2857 <div ref={inFlowSearchRef} className="flex flex-wrap items-center gap-2">
2858 <div className="min-w-0 basis-full @lg/pr-list:basis-0 @lg/pr-list:flex-1">
2859 {searchInput}
2860 </div>
2861 {sortMenu}
2862 {filtersMenu}
2863 {mergebotControl}
2864 <CompactFilterMenu
2865 label="Filter by provider"
2866 outlined
2867 iconOnly={host !== undefined}
2868 triggerIcon={<Plug2Icon aria-hidden className="size-4" />}
2869 triggerLabel="All"
2870 value={host ?? ""}
2871 options={hostMenuOptions}
2872 onChange={(next) => onHost(next === "" ? undefined : next)}
2873 />
2874 {!condensed ? (
2875 <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} />
2876 ) : null}
2877 </div>
2878 {/* Scrolled past this marker, the controls are gone and the title takes over. */}
2879 <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" />
2880 </div>
2881
2882 {listBody}
2883 </WorkspacePageContainer>
2884 </div>
2885 </div>
2886 );
2887}
2888
2889function PullRequestRefreshControl({
2890 compact = false,
2891 refreshing,
2892 onRefresh,
2893}: {
2894 compact?: boolean;
2895 refreshing: boolean;
2896 onRefresh: () => void;
2897}) {
2898 return (
2899 <Button
2900 size={compact ? "icon-sm" : "icon"}
2901 variant={compact ? "ghost" : "outline"}
2902 aria-label="Refresh pull requests"
2903 onClick={onRefresh}
2904 disabled={refreshing}
2905 >
2906 <RefreshIcon size="md" refreshing={refreshing} />
2907 </Button>
2908 );
2909}