1import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities";
4import {
5 threadPullRequestKeysEqual,
6 visibleThreadPullRequests,
7} from "@t3tools/shared/threadPullRequests";
8import type {
9 ContextMenuItem,
10 EnvironmentId,
11 PreviewSessionSnapshot,
12 ProjectId,
13 PullRequestState,
14 ResolvedKeybindingsConfig,
15} from "@t3tools/contracts";
16import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
17import {
18 closestCenter,
19 DndContext,
20 type DragEndEvent,
21 PointerSensor,
22 useSensor,
23 useSensors,
24} from "@dnd-kit/core";
25import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers";
26import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
27import { CSS } from "@dnd-kit/utilities";
28import {
29 Smartphone,
30 ChevronDown,
31 ChevronLeft,
32 ChevronRight,
33 FileDiff,
34 Files,
35 Globe2,
36 Plus,
37 TerminalSquare,
38 RocketIcon,
39} from "lucide-react";
40import { Volume2, VolumeOff } from "lucide";
41import {
42 type ComponentProps,
43 type KeyboardEvent as ReactKeyboardEvent,
44 type MouseEvent as ReactMouseEvent,
45 type ReactElement,
46 type ReactNode,
47 useCallback,
48 useEffect,
49 useEffectEvent,
50 useMemo,
51 useRef,
52 useState,
53} from "react";
54
55import { isElectron } from "~/env";
56import type { DesktopPreviewOverlay } from "~/previewStateStore";
57import type { RightPanelSurface } from "~/rightPanelStore";
58import { cn } from "~/lib/utils";
59import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings";
60import { readLocalApi } from "~/localApi";
61import { Button } from "~/components/ui/button";
62import { MorphIcon } from "~/components/MorphIcon";
63import { AndroidIcon, AppleIcon } from "~/components/Icons";
64import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
65import { Kbd } from "~/components/ui/kbd";
66import {
67 Menu,
68 MenuItem,
69 MenuPopup,
70 MenuShortcut,
71 MenuSub,
72 MenuSubPopup,
73 MenuSubTrigger,
74 MenuTrigger,
75} from "~/components/ui/menu";
76import { useBrowserDefaults } from "~/browser/browserDefaults";
77import { ScrollArea } from "~/components/ui/scroll-area";
78import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
79import { faviconUrlForOrigin } from "~/lib/favicon";
80import { useTheme } from "~/hooks/useTheme";
81import { useDeviceState } from "~/state/device";
82import type { PreviewPanelInlineSize } from "~/hooks/usePreviewPanelInlineSize";
83import {
84 newestPullRequestSummary,
85 pullRequestEnvironment,
86 useSharedPullRequestSummary,
87} from "~/state/pullRequests";
88import { useEnvironmentQuery } from "~/state/query";
89import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
90
91import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";
92import { FaviconImage } from "./preview/PreviewFaviconIcon";
93import { previewBridge } from "./preview/previewBridge";
94import { PierreEntryIcon } from "./chat/PierreEntryIcon";
95import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
96import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
97import { observeResize } from "~/lib/observeResize";
98
99interface RightPanelTabsProps {
100 mode: PreviewPanelMode;
101 maximized?: boolean;
102 open?: boolean;
103 keybindings: ResolvedKeybindingsConfig;
104 getShortcutContext: () => ShortcutMatchContext;
105 /** Forwarded to PreviewPanelShell so this surface persists its own width. */
106 widthStorageKey?: string;
107 /** Forwarded to PreviewPanelShell as the initial width before a user resize. */
108 defaultWidth?: number;
109 inlineSize?: PreviewPanelInlineSize;
110 layoutControls?: ReactNode;
111 surfaces: readonly RightPanelSurface[];
112 /** Fallback environment for surfaces that do not carry their own. */
113 environmentId: EnvironmentId | null;
114 activeSurfaceId: string | null;
115 pendingSurfaceIds: ReadonlySet<string>;
116 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;
117 desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>;
118 /**
119 * Maps a server session tab id to the desktop runtime tab id the Electron
120 * preview manager is keyed by. Session ids are only unique within one server
121 * process, so desktop operations must not be addressed with them.
122 */
123 previewRuntimeTabId?: ((tabId: string) => string) | undefined;
124 terminalLabelsById: ReadonlyMap<string, string>;
125 onActivate: (surface: RightPanelSurface) => void;
126 onRenameDevice?: (surfaceId: string, title: string) => void;
127 onCloseSurface: (surface: RightPanelSurface) => void;
128 onCloseOtherSurfaces: (surface: RightPanelSurface) => void;
129 onCloseSurfacesToRight: (surface: RightPanelSurface) => void;
130 onCloseAllSurfaces: () => void;
131 /** Tabs are draggable only when the owner can persist the new order. */
132 onMoveSurface?: (surfaceId: string, toIndex: number) => void;
133 onCopyFilePath: (relativePath: string) => void;
134 onAddBrowser: () => void;
135 /**
136 * Separate from `onAddBrowser` on purpose: that one is passed directly as a
137 * DOM click handler, and a `(profileId?: string)` signature would silently
138 * accept the MouseEvent as a profile id.
139 */
140 onAddBrowserInProfile: (profileId: string) => void;
141 onAddTerminal: () => void;
142 onAddDiff: () => void;
143 onAddFiles: () => void;
144 onAddPullRequest: () => void;
145 onAddPullRequests: () => void;
146 onAddDevice: () => void;
147 browserAvailable: boolean;
148 terminalAvailable: boolean;
149 diffAvailable: boolean;
150 filesAvailable: boolean;
151 pullRequestAvailable: boolean;
152 pullRequestsAvailable: boolean;
153 deviceAvailable: boolean;
154 pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>;
155 children: ReactNode;
156}
157
158export interface PullRequestTabStatus {
159 projectId: string;
160 repository: string;
161 number: number;
162 state: PullRequestState;
163 isDraft: boolean;
164}
165
166export type PullRequestTabStatusSeed = Pick<PullRequestTabStatus, "state" | "isDraft">;
167
168export function shouldOpenDefaultBrowserProfileFromMenuClick(
169 pointerType: string | undefined,
170): boolean {
171 return pointerType !== "touch";
172}
173
174const SURFACE_DISABLED_REASONS = {
175 browser: "Browser previews are only available in The Knife desktop app.",
176 terminal: "Terminal surfaces are only available from a project thread.",
177 files: "Files are only available when a project is open.",
178 diff: "Diff is only available for server threads in Git or Jujutsu workspaces.",
179 pullRequest: "This thread's branch has no pull request yet.",
180 pullRequests: "No linked pull requests are available for this thread.",
181 device: "Devices are only available from a thread.",
182} as const;
183
184/** Overlays that must win over the launcher's letter shortcuts. */
185const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [
186 '[data-slot="dialog-popup"]',
187 '[data-slot="alert-dialog-popup"]',
188 '[data-slot="command-dialog-popup"]',
189 '[data-slot="menu-popup"]',
190 '[data-slot="select-popup"]',
191 '[data-slot="popover-popup"]',
192 '[data-slot="combobox-popup"]',
193 '[data-slot="autocomplete-popup"]',
194].join(",");
195
196/** One-line unavailability hints for the empty-state rows. */
197const SURFACE_UNAVAILABLE_HINTS = {
198 browser: "Only available in the desktop app.",
199 terminal: "Available when a project is open.",
200 files: "Available when a project is open.",
201 diff: "Available for Git or Jujutsu workspaces.",
202 pullRequest: "No pull request on this branch yet.",
203 pullRequests: "No linked pull requests available.",
204 device: "Available from a thread.",
205} as const;
206
207type TabContextMenuAction =
208 | "rename"
209 | "copy-path"
210 | "toggle-mute"
211 | "close"
212 | "close-others"
213 | "close-to-right"
214 | "close-all";
215
216const TAB_SCROLL_EDGE_TOLERANCE = 1;
217const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor];
218
219function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null {
220 return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null;
221}
222
223/**
224 * Desktop preview tab backing a surface, or null for non-preview surfaces, the
225 * "new browser tab" placeholder, and the web build where no desktop tab exists.
226 */
227function previewTabIdOf(
228 surface: RightPanelSurface,
229 sessions: Readonly<Record<string, PreviewSessionSnapshot>>,
230): string | null {
231 if (surface.kind !== "preview" || !surface.resourceId) return null;
232 return sessions[surface.resourceId]?.tabId ?? null;
233}
234
235/**
236 * Label and enabled state for a preview tab's mute menu entry.
237 * Stays disabled until desktop overlay state arrives: a server session id can
238 * resolve while the preview manager's createTab is still in flight, and muting
239 * then fails with a PreviewTabNotFoundError nothing surfaces to the user.
240 */
241export function tabMuteMenuItem(input: {
242 overlay: DesktopPreviewOverlay | null;
243 canResolveRuntimeTabId: boolean;
244}): { label: string; disabled: boolean } {
245 const muted = input.overlay?.audioMuted ?? false;
246 return {
247 label: muted ? "Unmute tab" : "Mute tab",
248 disabled: input.overlay === null || !input.canResolveRuntimeTabId,
249 };
250}
251
252type TabAudioState = "none" | "audible" | "muted";
253
254/**
255 * A muted tab that is not making sound shows nothing: mute is armed silently,
256 * and the indicator only appears once there is audio to speak of.
257 */
258function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState {
259 if (!overlay?.audible) return "none";
260 return overlay.audioMuted ? "muted" : "audible";
261}
262
263type SurfaceShortcutEvent = Pick<
264 KeyboardEvent,
265 "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey"
266>;
267
268export function surfaceShortcutActionForKey<
269 const Action extends { available: boolean; shortcut: string },
270>(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null {
271 if (event.defaultPrevented || event.isComposing) return null;
272 if (event.metaKey || event.ctrlKey || event.altKey) return null;
273 return (
274 actions.find(
275 (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(),
276 ) ?? null
277 );
278}
279
280/**
281 * A focused editable is a typing context whether or not it has text yet: an
282 * empty chat composer at rest is still where the user's next keystrokes are
283 * meant to land, and claiming launcher letters from it would redirect prompts
284 * into whatever surface opens. The `:not` clause lets `closest` see past
285 * non-editable islands (`contenteditable="false"`) to an editable host around
286 * them, matching ComposerPendingUserInputPanel's typing guard.
287 */
288export function surfaceShortcutTargetsTypingContext(
289 target: { closest(selectors: string): unknown } | null,
290): boolean {
291 return (
292 target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !=
293 null
294 );
295}
296
297function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {
298 return (
299 <Tooltip>
300 <TooltipTrigger render={props.trigger} />
301 <TooltipPopup side="top">{props.reason}</TooltipPopup>
302 </Tooltip>
303 );
304}
305
306function SurfaceMenuItem(props: {
307 available: boolean;
308 disabledReason?: string;
309 shortcut: string;
310 onClick: () => void;
311 children: ReactNode;
312}) {
313 const item = (
314 <MenuItem
315 className={!props.available ? "data-disabled:pointer-events-auto" : undefined}
316 onClick={props.onClick}
317 disabled={!props.available}
318 aria-keyshortcuts={props.shortcut}
319 >
320 {props.children}
321 <MenuShortcut>{props.shortcut}</MenuShortcut>
322 </MenuItem>
323 );
324 if (props.available || !props.disabledReason) return item;
325 return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />;
326}
327
328/**
329 * List launcher shown when the right panel has no surfaces. Keyboard-first
330 * without palette chrome: a surface's letter opens it directly from anywhere
331 * outside a typing context, and arrows plus Enter work while the launcher is
332 * focused. The highlight only appears on hover or arrow use. Unavailable
333 * surfaces stay visible with a one-line reason.
334 */
335function RightPanelEmptyState(props: {
336 onAddBrowser: () => void;
337 onAddBrowserInProfile: (profileId: string) => void;
338 browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>;
339 onAddTerminal: () => void;
340 onAddDiff: () => void;
341 onAddFiles: () => void;
342 onAddPullRequest: () => void;
343 onAddPullRequests: () => void;
344 onAddDevice: () => void;
345 browserAvailable: boolean;
346 terminalAvailable: boolean;
347 diffAvailable: boolean;
348 filesAvailable: boolean;
349 pullRequestAvailable: boolean;
350 pullRequestsAvailable: boolean;
351 deviceAvailable: boolean;
352}) {
353 // -1 means no highlight: it only appears on hover or arrow use.
354 const [highlight, setHighlight] = useState(-1);
355
356 const actions = [
357 {
358 label: "Browser",
359 icon: Globe2,
360 shortcut: "B",
361 available: props.browserAvailable,
362 disabledReason: SURFACE_UNAVAILABLE_HINTS.browser,
363 onClick: props.onAddBrowser,
364 },
365 {
366 label: "Terminal",
367 icon: TerminalSquare,
368 shortcut: "T",
369 available: props.terminalAvailable,
370 disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal,
371 onClick: props.onAddTerminal,
372 },
373 {
374 label: "Files",
375 icon: Files,
376 shortcut: "F",
377 available: props.filesAvailable,
378 disabledReason: SURFACE_UNAVAILABLE_HINTS.files,
379 onClick: props.onAddFiles,
380 },
381 {
382 label: "Diff",
383 icon: FileDiff,
384 shortcut: "D",
385 available: props.diffAvailable,
386 disabledReason: SURFACE_UNAVAILABLE_HINTS.diff,
387 onClick: props.onAddDiff,
388 },
389 {
390 label: "Pull request",
391 icon: PullRequestGlyph.pullRequest,
392 shortcut: "P",
393 available: props.pullRequestAvailable,
394 disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest,
395 onClick: props.onAddPullRequest,
396 },
397 {
398 label: "Linked pull requests",
399 icon: PullRequestGlyph.link,
400 shortcut: "L",
401 available: props.pullRequestsAvailable,
402 disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests,
403 onClick: props.onAddPullRequests,
404 },
405 {
406 label: "Device",
407 description: "Watch an iOS Simulator or Android Emulator.",
408 icon: Smartphone,
409 shortcut: "M",
410 available: props.deviceAvailable,
411 disabledReason: SURFACE_UNAVAILABLE_HINTS.device,
412 onClick: props.onAddDevice,
413 },
414 ] as const;
415
416 type SurfaceAction = (typeof actions)[number];
417
418 const availableActions = actions.filter((action) => action.available);
419 const highlightIndex =
420 availableActions.length === 0 ? -1 : Math.min(highlight, availableActions.length - 1);
421
422 // Letter shortcuts work while the launcher is visible, not only while it
423 // is focused; focus moves around too easily (stray clicks) to carry them.
424 // Capture phase so app-level key handlers cannot swallow the event first;
425 // typing contexts and already-handled events are left alone.
426 const shortcutActionsRef = useRef(availableActions);
427 useEffect(() => {
428 shortcutActionsRef.current = availableActions;
429 });
430 useEffect(() => {
431 const handler = (event: KeyboardEvent) => {
432 const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event);
433 if (!action) return;
434 if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;
435 // The composed path starts at the real target, which may sit inside a shadow root.
436 const target = event.composedPath()[0] ?? event.target;
437 if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return;
438 event.preventDefault();
439 event.stopPropagation();
440 action.onClick();
441 };
442 window.addEventListener("keydown", handler, true);
443 return () => window.removeEventListener("keydown", handler, true);
444 }, []);
445
446 const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
447 if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
448 if (availableActions.length === 0) return;
449 if (event.key === "ArrowDown" || event.key === "ArrowRight") {
450 event.preventDefault();
451 setHighlight((highlightIndex + 1) % availableActions.length);
452 return;
453 }
454 if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
455 event.preventDefault();
456 setHighlight(
457 highlightIndex === -1
458 ? availableActions.length - 1
459 : (highlightIndex - 1 + availableActions.length) % availableActions.length,
460 );
461 return;
462 }
463 if (event.key === "Enter") {
464 // Only activate the highlight when the launcher itself has focus.
465 if (event.target !== event.currentTarget) return;
466 const action = availableActions[highlightIndex];
467 if (!action) return;
468 event.preventDefault();
469 action.onClick();
470 }
471 };
472
473 // Stable identity so React only runs this callback ref on mount/unmount;
474 // an inline arrow would re-attach and re-focus on every render.
475 const focusOnMount = useCallback((node: HTMLDivElement | null) => {
476 node?.focus();
477 }, []);
478
479 const isHighlighted = (action: SurfaceAction) =>
480 highlightIndex !== -1 && availableActions[highlightIndex] === action;
481
482 const actionIcon = (action: SurfaceAction, iconClassName = "size-4") => {
483 const Icon = action.icon;
484 return (
485 <span className="relative inline-flex shrink-0">
486 <Icon className={iconClassName} />
487 </span>
488 );
489 };
490
491 return (
492 <div
493 ref={focusOnMount}
494 tabIndex={0}
495 onKeyDown={handleKeyDown}
496 aria-label="Open a surface"
497 data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")}
498 className={cn(
499 "scrollbar-gutter-both flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none",
500 // The panel topbar sits above this container; matching bottom padding
501 // keeps the list centered against the full panel, not the leftover.
502 "pb-(--workspace-topbar-height)",
503 )}
504 >
505 <div className="w-full max-w-xs py-6">
506 <h3 className="mb-3 text-center font-medium text-foreground text-sm">Open a surface</h3>
507 <div className="flex flex-col gap-0.5">
508 {actions.map((action) =>
509 action.available ? (
510 // The row is itself a button, so the profile chooser sits beside
511 // it in a wrapper rather than inside it. Hover lives on the
512 // wrapper: the chooser overlays the row, and a pointer moving
513 // onto it must not read as leaving the row.
514 <div
515 key={action.label}
516 className="group relative"
517 onMouseEnter={() => setHighlight(availableActions.indexOf(action))}
518 onMouseLeave={() =>
519 setHighlight((current) =>
520 current === availableActions.indexOf(action) ? -1 : current,
521 )
522 }
523 >
524 <button
525 type="button"
526 onClick={action.onClick}
527 className={cn(
528 "flex h-8 w-full cursor-pointer items-center gap-2.5 rounded-(--control-radius) px-2.5 text-left text-sm transition-colors group-hover:bg-accent/60",
529 isHighlighted(action) && "bg-accent/60",
530 )}
531 >
532 {actionIcon(action, "size-4")}
533 <span
534 className={cn(
535 "min-w-0 flex-1 truncate",
536 action.label === "Browser" && props.browserProfiles.length > 1 && "pr-7",
537 )}
538 >
539 {action.label}
540 </span>
541 <Kbd>{action.shortcut}</Kbd>
542 </button>
543 {/*
544 Same choice the tab bar's "+" menu offers: the row opens the
545 default profile, the chevron picks another. Only worth showing
546 once there is something to choose between.
547 */}
548 {action.label === "Browser" && props.browserProfiles.length > 1 ? (
549 <Menu>
550 <MenuTrigger
551 render={
552 <Button
553 aria-label="Open browser in a profile"
554 className="absolute top-1/2 right-8 -translate-y-1/2"
555 size="icon-xs"
556 variant="ghost-muted"
557 />
558 }
559 >
560 <ChevronDown className="size-3.5" />
561 </MenuTrigger>
562 <MenuPopup align="end" side="bottom" sideOffset={6} className="max-w-56">
563 {props.browserProfiles.map((profile) => (
564 <MenuItem
565 key={profile.id}
566 onClick={() => props.onAddBrowserInProfile(profile.id)}
567 >
568 <span className="min-w-0 truncate">{profile.name}</span>
569 </MenuItem>
570 ))}
571 </MenuPopup>
572 </Menu>
573 ) : null}
574 </div>
575 ) : (
576 <DisabledReasonTooltip
577 key={action.label}
578 reason={action.disabledReason}
579 trigger={
580 <div
581 tabIndex={0}
582 aria-disabled="true"
583 className="flex h-8 w-full cursor-default items-center gap-2.5 rounded-(--control-radius) px-2.5 text-left text-sm opacity-50"
584 >
585 {actionIcon(action, "size-4")}
586 <span className="min-w-0 flex-1 truncate">{action.label}</span>
587 <Kbd>{action.shortcut}</Kbd>
588 </div>
589 }
590 />
591 ),
592 )}
593 </div>
594 </div>
595 </div>
596 );
597}
598
599function surfaceTitle(
600 surface: RightPanelSurface,
601 sessions: Readonly<Record<string, PreviewSessionSnapshot>>,
602 terminalLabelsById: ReadonlyMap<string, string>,
603): string {
604 switch (surface.kind) {
605 case "diff":
606 return "Diff";
607 case "files":
608 return "Files";
609 case "file":
610 return surface.relativePath.slice(
611 Math.max(surface.relativePath.lastIndexOf("/"), surface.relativePath.lastIndexOf("\\")) + 1,
612 );
613 case "terminal":
614 return (
615 terminalLabelsById.get(surface.activeTerminalId) ??
616 getTerminalLabel(surface.activeTerminalId)
617 );
618 case "pull-request":
619 return `#${surface.number}`;
620 case "pull-requests":
621 return "Pull requests";
622 case "deployments":
623 return "Deployments";
624 case "device":
625 return surface.title ?? surface.target?.name ?? "Device";
626 case "preview": {
627 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;
628 if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser";
629 if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title;
630 try {
631 return new URL(snapshot.navStatus.url).host || "Browser";
632 } catch {
633 return "Browser";
634 }
635 }
636 }
637}
638
639function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) {
640 const publicProviderUrl = faviconUrlForOrigin(url, 32);
641 return (
642 <FaviconImage
643 sources={[capturedUrl, publicProviderUrl]}
644 fallback={<Globe2 className="size-3 shrink-0" />}
645 className="size-3 shrink-0 rounded-sm object-contain"
646 />
647 );
648}
649
650function sameOrigin(left: string, right: string): boolean {
651 try {
652 return new URL(left).origin === new URL(right).origin;
653 } catch {
654 return false;
655 }
656}
657
658function SurfaceIcon({
659 surface,
660 sessions,
661 desktopByTabId,
662 theme,
663 environmentId,
664 pullRequestStatusSeeds,
665}: {
666 surface: RightPanelSurface;
667 sessions: Readonly<Record<string, PreviewSessionSnapshot>>;
668 desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>;
669 theme: "light" | "dark";
670 environmentId: EnvironmentId | null;
671 pullRequestStatusSeeds: Readonly<Record<string, PullRequestTabStatusSeed>> | undefined;
672}) {
673 switch (surface.kind) {
674 case "preview": {
675 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;
676 const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
677 const favicon = snapshot ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) : null;
678 const capturedUrl =
679 favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null;
680 return <PreviewFavicon capturedUrl={capturedUrl} url={url} />;
681 }
682 case "diff":
683 return <FileDiff className="size-3 shrink-0" />;
684 case "files":
685 return <Files className="size-3 shrink-0" />;
686 case "file":
687 return (
688 <PierreEntryIcon
689 pathValue={surface.relativePath}
690 kind="file"
691 theme={theme}
692 className="size-3"
693 />
694 );
695 case "terminal":
696 return <TerminalSquare className="size-3 shrink-0" />;
697 case "pull-request":
698 return (
699 <PullRequestSurfaceIcon
700 surface={surface}
701 environmentId={environmentId}
702 seed={pullRequestStatusSeeds?.[surface.id]}
703 />
704 );
705 case "pull-requests":
706 return <PullRequestGlyph.link className="size-3 shrink-0" />;
707 case "deployments":
708 return <RocketIcon className="size-3 shrink-0" />;
709 case "device":
710 return surface.target?.platform === "ios" ? (
711 <AppleIcon className="size-3 shrink-0" />
712 ) : surface.target?.platform === "android" ? (
713 <AndroidIcon className="size-3 shrink-0" />
714 ) : (
715 <Smartphone className="size-3 shrink-0" />
716 );
717 }
718}
719
720export function resolvePullRequestTabLink(
721 threads: readonly Pick<EnvironmentThreadShell, "environmentId" | "pullRequests">[],
722 environmentId: EnvironmentId | null,
723 host: string | null,
724 reference: { repository: string; number: number },
725) {
726 if (environmentId === null || host === null) return undefined;
727 let newest: EnvironmentThreadShell["pullRequests"][number] | undefined;
728 for (const thread of threads) {
729 if (thread.environmentId !== environmentId) continue;
730 for (const link of visibleThreadPullRequests(thread.pullRequests)) {
731 if (
732 !threadPullRequestKeysEqual(link, {
733 host,
734 repository: reference.repository,
735 number: reference.number,
736 })
737 )
738 continue;
739 if (
740 newest === undefined ||
741 (link.snapshot?.syncedAt ?? "") > (newest.snapshot?.syncedAt ?? "")
742 )
743 newest = link;
744 }
745 }
746 return newest;
747}
748
749function PullRequestSurfaceIcon({
750 surface,
751 environmentId,
752 seed,
753}: {
754 surface: Extract<RightPanelSurface, { kind: "pull-request" }>;
755 environmentId: EnvironmentId | null;
756 seed: PullRequestTabStatusSeed | undefined;
757}) {
758 const resolvedEnvironmentId =
759 (surface.environmentId as EnvironmentId | undefined) ?? environmentId;
760 const projects = useProjects();
761 const threads = useThreadShells();
762 const project = projects.find(
763 (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId,
764 );
765 const identity = project?.repositoryIdentity;
766 const host =
767 surface.host ??
768 (identity?.provider
769 ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind)
770 : null);
771 const configs = useServerConfigs();
772 const capabilities =
773 resolvedEnvironmentId === null
774 ? undefined
775 : configs.get(resolvedEnvironmentId)?.environment.capabilities;
776 const linkedSnapshot =
777 capabilities?.threadPullRequests === true
778 ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null)
779 : null;
780 const detail = useEnvironmentQuery(
781 resolvedEnvironmentId === null || capabilities?.pullRequests !== true || linkedSnapshot !== null
782 ? null
783 : pullRequestEnvironment.detail({
784 environmentId: resolvedEnvironmentId,
785 input: {
786 projectId: surface.projectId as ProjectId,
787 ...(capabilities?.threadPullRequests === true && surface.host !== undefined
788 ? { host: surface.host }
789 : {}),
790 repository: surface.repository,
791 number: surface.number,
792 },
793 }),
794 ).data;
795 const reference = useMemo(
796 () => ({
797 projectId: surface.projectId as ProjectId,
798 repository: surface.repository,
799 number: surface.number,
800 }),
801 [surface.projectId, surface.repository, surface.number],
802 );
803 const sharedSummary = useSharedPullRequestSummary(resolvedEnvironmentId, reference, null);
804 // The compact tab intentionally shows lifecycle and draft state only. Conflict warnings have
805 // their own presentation on surfaces that have mergeability, while this tab stays stable as
806 // detail data arrives.
807 const status = linkedSnapshot ?? newestPullRequestSummary(detail, sharedSummary) ?? seed ?? null;
808 if (status === null) {
809 return <PullRequestGlyph.pullRequest className="size-3 shrink-0 text-muted-foreground" />;
810 }
811 const presentation = resolvePullRequestState({
812 state: status.state,
813 isDraft: status.isDraft ?? detail?.isDraft ?? seed?.isDraft ?? false,
814 });
815 return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />;
816}
817
818/**
819 * Lets its tabs be dragged into a new order. Neighbours slide aside while dragging,
820 * and the order commits on drop.
821 */
822function SortableTabList(props: {
823 surfaces: readonly RightPanelSurface[];
824 onMove: ((surfaceId: string, toIndex: number) => void) | undefined;
825 children: ReactNode;
826}) {
827 const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
828 const surfaceIds = useMemo(() => props.surfaces.map((surface) => surface.id), [props.surfaces]);
829 const { onMove, surfaces } = props;
830 const handleDragEnd = useCallback(
831 ({ active, over }: DragEndEvent) => {
832 if (!over || active.id === over.id) return;
833 const toIndex = surfaces.findIndex((surface) => surface.id === over.id);
834 if (toIndex >= 0) onMove?.(String(active.id), toIndex);
835 },
836 [onMove, surfaces],
837 );
838 return (
839 <DndContext
840 sensors={sensors}
841 collisionDetection={closestCenter}
842 modifiers={TAB_DRAG_MODIFIERS}
843 onDragEnd={handleDragEnd}
844 >
845 <SortableContext items={surfaceIds} strategy={horizontalListSortingStrategy}>
846 {props.children}
847 </SortableContext>
848 </DndContext>
849 );
850}
851
852function SortableTab({
853 surfaceId,
854 disabled,
855 className,
856 ...props
857}: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) {
858 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
859 id: surfaceId,
860 disabled,
861 });
862 return (
863 <div
864 {...props}
865 {...listeners}
866 ref={setNodeRef}
867 style={{ transform: CSS.Translate.toString(transform), transition }}
868 className={cn(className, isDragging && "relative z-10")}
869 />
870 );
871}
872
873export function RightPanelTabs(props: RightPanelTabsProps) {
874 const ownsDesktopTitleBar = isElectron && props.mode === "inline";
875 const browserProfiles = useBrowserDefaults().profiles;
876 const { resolvedTheme } = useTheme();
877 const tabListRef = useRef<HTMLDivElement>(null);
878 const addSurfaceTriggerRef = useRef<HTMLButtonElement>(null);
879 const [renamingDevice, setRenamingDevice] = useState<string | null>(null);
880 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);
881 const [tabScrollState, setTabScrollState] = useState({
882 hasOverflow: false,
883 canScrollLeft: false,
884 canScrollRight: false,
885 });
886
887 if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false);
888
889 const onNewSurfaceKeyDown = useEffectEvent((event: KeyboardEvent) => {
890 if (event.defaultPrevented || event.isComposing) return;
891 if (
892 resolveShortcutCommand(event, props.keybindings, {
893 context: { ...props.getShortcutContext(), rightPanelOpen: true },
894 }) !== "rightPanel.new"
895 )
896 return;
897 if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;
898 event.preventDefault();
899 event.stopPropagation();
900 if (!event.repeat) {
901 addSurfaceTriggerRef.current?.focus();
902 setAddSurfaceMenuOpen(true);
903 }
904 });
905 useEffect(() => {
906 if (props.open === false) return;
907 document.addEventListener("keydown", onNewSurfaceKeyDown, true);
908 return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true);
909 }, [props.open]);
910
911 const updateTabScrollState = useCallback(() => {
912 const viewport = tabScrollViewport(tabListRef.current);
913 if (!viewport) return;
914
915 const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE;
916 const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE;
917 const canScrollRight =
918 hasOverflow &&
919 viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - TAB_SCROLL_EDGE_TOLERANCE;
920 setTabScrollState((current) => {
921 if (
922 current.hasOverflow === hasOverflow &&
923 current.canScrollLeft === canScrollLeft &&
924 current.canScrollRight === canScrollRight
925 ) {
926 return current;
927 }
928 return { hasOverflow, canScrollLeft, canScrollRight };
929 });
930 }, []);
931
932 const scrollTabs = useCallback((direction: -1 | 1) => {
933 const viewport = tabScrollViewport(tabListRef.current);
934 if (!viewport) return;
935 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
936 viewport.scrollBy({
937 left: direction * Math.max(120, viewport.clientWidth * 0.75),
938 behavior: reduceMotion ? "auto" : "smooth",
939 });
940 }, []);
941
942 const addSurfaceActions = [
943 {
944 label: "Browser",
945 icon: Globe2,
946 shortcut: "B",
947 available: props.browserAvailable,
948 disabledReason: SURFACE_DISABLED_REASONS.browser,
949 onClick: props.onAddBrowser,
950 },
951 {
952 label: "Terminal",
953 icon: TerminalSquare,
954 shortcut: "T",
955 available: props.terminalAvailable,
956 disabledReason: SURFACE_DISABLED_REASONS.terminal,
957 onClick: props.onAddTerminal,
958 },
959 {
960 label: "Files",
961 icon: Files,
962 shortcut: "F",
963 available: props.filesAvailable,
964 disabledReason: SURFACE_DISABLED_REASONS.files,
965 onClick: props.onAddFiles,
966 },
967 {
968 label: "Diff",
969 icon: FileDiff,
970 shortcut: "D",
971 available: props.diffAvailable,
972 disabledReason: SURFACE_DISABLED_REASONS.diff,
973 onClick: props.onAddDiff,
974 },
975 {
976 label: "Pull request",
977 icon: PullRequestGlyph.pullRequest,
978 shortcut: "P",
979 available: props.pullRequestAvailable,
980 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,
981 onClick: props.onAddPullRequest,
982 },
983 {
984 label: "Linked pull requests",
985 icon: PullRequestGlyph.link,
986 shortcut: "L",
987 available: props.pullRequestsAvailable,
988 disabledReason: SURFACE_DISABLED_REASONS.pullRequests,
989 onClick: props.onAddPullRequests,
990 },
991 {
992 label: "Device",
993 icon: Smartphone,
994 shortcut: "M",
995 available: props.deviceAvailable,
996 disabledReason: SURFACE_DISABLED_REASONS.device,
997 onClick: props.onAddDevice,
998 },
999 ] as const;
1000
1001 const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
1002 const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent);
1003 if (!action) return;
1004 event.preventDefault();
1005 event.stopPropagation();
1006 setAddSurfaceMenuOpen(false);
1007 action.onClick();
1008 };
1009
1010 const handleTabContextMenu = useCallback(
1011 async (event: ReactMouseEvent, surface: RightPanelSurface) => {
1012 event.preventDefault();
1013 event.stopPropagation();
1014
1015 const api = readLocalApi();
1016 if (!api) return;
1017
1018 const surfaceIndex = props.surfaces.findIndex((entry) => entry.id === surface.id);
1019 if (surfaceIndex < 0) return;
1020
1021 const items: ContextMenuItem<TabContextMenuAction>[] = [];
1022 if (surface.kind === "device" && props.onRenameDevice)
1023 items.push({ id: "rename", label: "Rename" });
1024 if (surface.kind === "file" && surface.attachment === undefined) {
1025 items.push({ id: "copy-path", label: "Copy path" });
1026 }
1027 const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions);
1028 // Desktop overlay state only arrives once the preview manager has created
1029 // the tab. A server session id alone can still be ahead of that, and
1030 // muting then fails with PreviewTabNotFoundError that nobody surfaces.
1031 const menuOverlay = menuPreviewTabId
1032 ? (props.desktopByTabId[menuPreviewTabId] ?? null)
1033 : null;
1034 const menuMuted = menuOverlay?.audioMuted ?? false;
1035 if (surface.kind === "preview") {
1036 // Not gated on audibility: silencing a quiet tab ahead of time is the
1037 // point, so the item is offered whenever the tab is mutable at all.
1038 items.push({
1039 id: "toggle-mute",
1040 ...tabMuteMenuItem({
1041 overlay: menuOverlay,
1042 canResolveRuntimeTabId: props.previewRuntimeTabId !== undefined,
1043 }),
1044 });
1045 }
1046 items.push(
1047 { id: "close", label: "Close" },
1048 {
1049 id: "close-others",
1050 label: "Close others",
1051 disabled: props.surfaces.length <= 1,
1052 },
1053 {
1054 id: "close-to-right",
1055 label: "Close to the right",
1056 disabled: surfaceIndex >= props.surfaces.length - 1,
1057 },
1058 {
1059 id: "close-all",
1060 label: "Close all",
1061 disabled: props.surfaces.length === 0,
1062 },
1063 );
1064
1065 const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY });
1066 switch (action) {
1067 case "rename":
1068 setRenamingDevice(surface.id);
1069 break;
1070 case "copy-path":
1071 if (surface.kind === "file" && surface.attachment === undefined) {
1072 props.onCopyFilePath(surface.relativePath);
1073 }
1074 break;
1075 case "toggle-mute": {
1076 // menuOverlay repeats the disabled gate above: the desktop tab must
1077 // exist before it can be addressed, however the menu was dismissed.
1078 const runtimeTabId =
1079 menuPreviewTabId && menuOverlay
1080 ? (props.previewRuntimeTabId?.(menuPreviewTabId) ?? null)
1081 : null;
1082 if (runtimeTabId) {
1083 void previewBridge?.setAudioMuted(runtimeTabId, !menuMuted).catch(() => undefined);
1084 }
1085 break;
1086 }
1087 case "close":
1088 props.onCloseSurface(surface);
1089 break;
1090 case "close-others":
1091 props.onCloseOtherSurfaces(surface);
1092 break;
1093 case "close-to-right":
1094 props.onCloseSurfacesToRight(surface);
1095 break;
1096 case "close-all":
1097 props.onCloseAllSurfaces();
1098 break;
1099 case null:
1100 break;
1101 }
1102 },
1103 [props],
1104 );
1105 const handleTabMouseDown = useCallback((event: ReactMouseEvent) => {
1106 if (event.button !== 1) return;
1107 event.preventDefault();
1108 }, []);
1109 const handleTabAuxClick = useCallback(
1110 (event: ReactMouseEvent, surface: RightPanelSurface) => {
1111 if (event.button !== 1) return;
1112 event.preventDefault();
1113 event.stopPropagation();
1114 props.onCloseSurface(surface);
1115 },
1116 [props],
1117 );
1118
1119 useEffect(() => {
1120 if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return;
1121 const activeTab = tabListRef.current?.querySelector<HTMLElement>("[data-active-tab='true']");
1122 activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" });
1123 }, [props.activeSurfaceId, tabScrollState.hasOverflow]);
1124
1125 useEffect(() => {
1126 const viewport = tabScrollViewport(tabListRef.current);
1127 if (!viewport) return;
1128
1129 const content = viewport.firstElementChild;
1130 const stopObserving = observeResize(
1131 content ? [viewport, content] : viewport,
1132 updateTabScrollState,
1133 );
1134 viewport.addEventListener("scroll", updateTabScrollState, { passive: true });
1135 updateTabScrollState();
1136
1137 return () => {
1138 stopObserving();
1139 viewport.removeEventListener("scroll", updateTabScrollState);
1140 };
1141 }, [updateTabScrollState]);
1142
1143 useEffect(() => {
1144 const viewport = tabScrollViewport(tabListRef.current);
1145 if (!viewport) return;
1146
1147 const handleWheel = (event: WheelEvent) => {
1148 if (event.ctrlKey) return;
1149 let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
1150 if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16;
1151 if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= viewport.clientWidth;
1152 if (delta === 0) return;
1153
1154 const previousScrollLeft = viewport.scrollLeft;
1155 viewport.scrollLeft += delta;
1156 if (viewport.scrollLeft === previousScrollLeft) return;
1157 event.preventDefault();
1158 updateTabScrollState();
1159 };
1160
1161 viewport.addEventListener("wheel", handleWheel, { passive: false });
1162 return () => viewport.removeEventListener("wheel", handleWheel);
1163 }, [updateTabScrollState]);
1164
1165 return (
1166 <PreviewPanelShell
1167 mode={props.mode}
1168 {...(props.maximized !== undefined ? { maximized: props.maximized } : {})}
1169 {...(props.open !== undefined ? { open: props.open } : {})}
1170 {...(props.widthStorageKey !== undefined ? { widthStorageKey: props.widthStorageKey } : {})}
1171 {...(props.defaultWidth !== undefined ? { defaultWidth: props.defaultWidth } : {})}
1172 {...(props.inlineSize ? { inlineSize: props.inlineSize } : {})}
1173 >
1174 <div
1175 className={cn(
1176 "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-1 pl-2",
1177 // The sheet overlays from the viewport top, so its tab bar keeps
1178 // the titlebar's height: a compact row re-centers the layout
1179 // controls a few pixels higher and the cluster jumps on open.
1180 props.mode === "inline" && !props.layoutControls ? "pr-28" : "pr-3",
1181 ownsDesktopTitleBar && "drag-region",
1182 ownsDesktopTitleBar && "wco:pr-(--workspace-native-controls-inset)",
1183 props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
1184 )}
1185 data-right-panel-tabbar
1186 >
1187 <ScrollArea
1188 radius="none"
1189 ref={tabListRef}
1190 hideScrollbars
1191 scrollFade
1192 className="min-w-0 flex-1"
1193 data-right-panel-tab-list
1194 >
1195 <div className="flex h-full w-max min-w-full items-center gap-1">
1196 <SortableTabList surfaces={props.surfaces} onMove={props.onMoveSurface}>
1197 {props.surfaces.map((surface) => {
1198 const active = surface.id === props.activeSurfaceId;
1199 const pending = props.pendingSurfaceIds.has(surface.id);
1200 const title = surfaceTitle(
1201 surface,
1202 props.previewSessions,
1203 props.terminalLabelsById,
1204 );
1205 const previewTabId = previewTabIdOf(surface, props.previewSessions);
1206 // Desktop state is keyed by the session id, but desktop actions
1207 // must be addressed with the runtime id.
1208 const audio = tabAudioState(
1209 previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null,
1210 );
1211 const audioRuntimeTabId = previewTabId
1212 ? (props.previewRuntimeTabId?.(previewTabId) ?? null)
1213 : null;
1214 return (
1215 <SortableTab
1216 key={surface.id}
1217 surfaceId={surface.id}
1218 disabled={!props.onMoveSurface || renamingDevice === surface.id}
1219 data-active-tab={active}
1220 onMouseDown={handleTabMouseDown}
1221 onAuxClick={(event) => handleTabAuxClick(event, surface)}
1222 onContextMenu={(event) => void handleTabContextMenu(event, surface)}
1223 className={cn(
1224 "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs",
1225 ownsDesktopTitleBar && "[-webkit-app-region:no-drag]",
1226 active
1227 ? "bg-accent text-foreground"
1228 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
1229 )}
1230 >
1231 <PanelTabCloseButton
1232 label={`Close ${title}`}
1233 onClick={() => props.onCloseSurface(surface)}
1234 >
1235 <SurfaceIcon
1236 surface={surface}
1237 sessions={props.previewSessions}
1238 desktopByTabId={props.desktopByTabId}
1239 theme={resolvedTheme}
1240 environmentId={props.environmentId}
1241 pullRequestStatusSeeds={props.pullRequestStatusSeeds}
1242 />
1243 {pending ? (
1244 <span
1245 className="absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full bg-current"
1246 aria-hidden
1247 />
1248 ) : null}
1249 </PanelTabCloseButton>
1250 {audio === "none" || !audioRuntimeTabId ? null : (
1251 <Tooltip>
1252 <TooltipTrigger
1253 render={
1254 <button
1255 type="button"
1256 className="cursor-pointer flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted"
1257 aria-label={audio === "muted" ? `Unmute ${title}` : `Mute ${title}`}
1258 onClick={(event) => {
1259 // Sibling of the close button, inside a tab that
1260 // activates on click: keep this to the toggle.
1261 event.stopPropagation();
1262 void previewBridge
1263 ?.setAudioMuted(audioRuntimeTabId, audio !== "muted")
1264 .catch(() => undefined);
1265 }}
1266 >
1267 <MorphIcon
1268 className="size-3"
1269 icon={audio === "muted" ? VolumeOff : Volume2}
1270 />
1271 </button>
1272 }
1273 />
1274 <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup>
1275 </Tooltip>
1276 )}
1277 {renamingDevice === surface.id ? (
1278 <input
1279 aria-label="Device tab name"
1280 className="w-24 min-w-0 rounded-sm bg-background px-1 outline-none ring-1 ring-inset ring-ring"
1281 defaultValue={title}
1282 ref={(element) => {
1283 element?.focus();
1284 element?.select();
1285 }}
1286 onBlur={(event) => {
1287 props.onRenameDevice?.(surface.id, event.currentTarget.value);
1288 setRenamingDevice(null);
1289 }}
1290 onKeyDown={(event) => {
1291 event.stopPropagation();
1292 if (event.key === "Enter") event.currentTarget.blur();
1293 if (event.key === "Escape") {
1294 event.currentTarget.value = title;
1295 event.currentTarget.blur();
1296 }
1297 }}
1298 />
1299 ) : (
1300 <Tooltip>
1301 <TooltipTrigger
1302 render={
1303 <button
1304 type="button"
1305 onDoubleClick={() => {
1306 if (surface.kind === "device" && props.onRenameDevice)
1307 setRenamingDevice(surface.id);
1308 }}
1309 className="cursor-pointer flex min-w-0 items-center"
1310 onClick={() => props.onActivate(surface)}
1311 >
1312 <span className="truncate">{title}</span>
1313 </button>
1314 }
1315 />
1316 <TooltipPopup>
1317 {surface.kind === "device" ? (
1318 <DeviceTabTooltip
1319 surface={surface}
1320 environmentId={props.environmentId}
1321 title={title}
1322 />
1323 ) : (
1324 title
1325 )}
1326 </TooltipPopup>
1327 </Tooltip>
1328 )}
1329 </SortableTab>
1330 );
1331 })}
1332 </SortableTabList>
1333 {props.open !== false ? (
1334 <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}>
1335 <MenuTrigger
1336 ref={addSurfaceTriggerRef}
1337 render={
1338 <Button
1339 aria-label="Add panel surface"
1340 className="shrink-0"
1341 size="icon-xs"
1342 variant="ghost-muted"
1343 />
1344 }
1345 >
1346 <Plus className="size-3.5" />
1347 </MenuTrigger>
1348 <MenuPopup
1349 align="start"
1350 side="bottom"
1351 sideOffset={6}
1352 onKeyDownCapture={handleAddSurfaceMenuKeyDown}
1353 >
1354 {addSurfaceActions.map((action) => {
1355 const Icon = action.icon;
1356 // Browser collapses into one row: clicking the trigger opens
1357 // the default profile (the common case stays one click),
1358 // while hover or arrow reveals the profiles. The choice
1359 // lives at open time because a tab's profile is fixed then —
1360 // Electron only honours a partition before attach.
1361 if (action.label === "Browser" && action.available) {
1362 return (
1363 <MenuSub key={action.label}>
1364 <MenuSubTrigger
1365 className="[&>svg:last-child]:ms-0"
1366 aria-keyshortcuts={action.shortcut}
1367 onClick={(event) => {
1368 const pointerType =
1369 "pointerType" in event.nativeEvent &&
1370 typeof event.nativeEvent.pointerType === "string"
1371 ? event.nativeEvent.pointerType
1372 : undefined;
1373 // Touch has no hover path to the profile choices:
1374 // its first tap opens the submenu, then a profile
1375 // is selected there. Mouse click keeps the common
1376 // default-profile action at one click.
1377 if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType))
1378 return;
1379 setAddSurfaceMenuOpen(false);
1380 action.onClick();
1381 }}
1382 >
1383 <Icon />
1384 {action.label}
1385 <MenuShortcut>{action.shortcut}</MenuShortcut>
1386 </MenuSubTrigger>
1387 {/*
1388 Capped and truncated: profile names are user-supplied
1389 and run to 48 characters, which would otherwise widen
1390 the popup to fit-content and wrap.
1391 */}
1392 <MenuSubPopup className="max-w-56">
1393 {browserProfiles.map((profile) => (
1394 <MenuItem
1395 key={profile.id}
1396 onClick={() => props.onAddBrowserInProfile(profile.id)}
1397 >
1398 <span className="min-w-0 truncate">{profile.name}</span>
1399 </MenuItem>
1400 ))}
1401 </MenuSubPopup>
1402 </MenuSub>
1403 );
1404 }
1405 return (
1406 <SurfaceMenuItem
1407 key={action.label}
1408 available={action.available}
1409 disabledReason={action.disabledReason}
1410 shortcut={action.shortcut}
1411 onClick={action.onClick}
1412 >
1413 <Icon />
1414 {action.label}
1415 </SurfaceMenuItem>
1416 );
1417 })}
1418 </MenuPopup>
1419 </Menu>
1420 ) : null}
1421 </div>
1422 </ScrollArea>
1423 {tabScrollState.hasOverflow ? (
1424 <div
1425 className="flex shrink-0 items-center gap-0.5 [-webkit-app-region:no-drag]"
1426 role="group"
1427 aria-label="Scroll panel tabs"
1428 >
1429 <Tooltip>
1430 <TooltipTrigger
1431 render={
1432 <span className="inline-flex">
1433 <Button
1434 aria-label="Scroll tabs left"
1435 disabled={!tabScrollState.canScrollLeft}
1436 onClick={() => scrollTabs(-1)}
1437 size="icon-xs"
1438 variant="ghost"
1439 >
1440 <ChevronLeft />
1441 </Button>
1442 </span>
1443 }
1444 />
1445 <TooltipPopup>Scroll tabs left</TooltipPopup>
1446 </Tooltip>
1447 <Tooltip>
1448 <TooltipTrigger
1449 render={
1450 <span className="inline-flex">
1451 <Button
1452 aria-label="Scroll tabs right"
1453 disabled={!tabScrollState.canScrollRight}
1454 onClick={() => scrollTabs(1)}
1455 size="icon-xs"
1456 variant="ghost"
1457 >
1458 <ChevronRight />
1459 </Button>
1460 </span>
1461 }
1462 />
1463 <TooltipPopup>Scroll tabs right</TooltipPopup>
1464 </Tooltip>
1465 </div>
1466 ) : null}
1467 {props.layoutControls}
1468 {ownsDesktopTitleBar && !props.layoutControls ? (
1469 // Keeps the tabs clear of the window controls when the layout toggles live elsewhere.
1470 <span aria-hidden className="hidden w-24 shrink-0 wco:block" />
1471 ) : null}
1472 {ownsDesktopTitleBar ? (
1473 <span
1474 aria-hidden
1475 className="pointer-events-none fixed top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] h-[var(--workspace-topbar-height)] w-28 [-webkit-app-region:no-drag]"
1476 />
1477 ) : null}
1478 </div>
1479 <div className="flex min-h-0 flex-1 flex-col" data-right-panel-surface-content>
1480 {props.activeSurfaceId === null ? (
1481 <RightPanelEmptyState
1482 onAddBrowser={props.onAddBrowser}
1483 onAddBrowserInProfile={props.onAddBrowserInProfile}
1484 browserProfiles={browserProfiles}
1485 onAddTerminal={props.onAddTerminal}
1486 onAddDiff={props.onAddDiff}
1487 onAddFiles={props.onAddFiles}
1488 onAddPullRequest={props.onAddPullRequest}
1489 onAddPullRequests={props.onAddPullRequests}
1490 onAddDevice={props.onAddDevice}
1491 browserAvailable={props.browserAvailable}
1492 terminalAvailable={props.terminalAvailable}
1493 diffAvailable={props.diffAvailable}
1494 filesAvailable={props.filesAvailable}
1495 pullRequestAvailable={props.pullRequestAvailable}
1496 pullRequestsAvailable={props.pullRequestsAvailable}
1497 deviceAvailable={props.deviceAvailable}
1498 />
1499 ) : (
1500 props.children
1501 )}
1502 </div>
1503 </PreviewPanelShell>
1504 );
1505}
1506
1507function DeviceTabTooltip(props: {
1508 surface: Extract<RightPanelSurface, { kind: "device" }>;
1509 environmentId: EnvironmentId | null;
1510 title: string;
1511}) {
1512 const target = props.surface.target;
1513 const { state } = useDeviceState(target ? props.environmentId : null);
1514 const device = target
1515 ? state.devices.find((entry) => entry.hostId === target.hostId && entry.id === target.deviceId)
1516 : undefined;
1517 const host = target ? state.hosts.find((entry) => entry.id === target.hostId) : undefined;
1518 return (
1519 <div className="flex flex-col gap-0.5">
1520 <span>{props.title}</span>
1521 {target ? (
1522 <span className="text-muted-foreground">
1523 {host?.label ?? "Device host"} ·{" "}
1524 {device?.version ?? (target.platform === "ios" ? "iOS" : "Android")}
1525 </span>
1526 ) : null}
1527 </div>
1528 );
1529}