Skip to content
Source
Sign inSign up
Open History to see the last commit

RightPanelTabs.tsx

1,289 lines46.3 KB

1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { useQuietThreadShell } from "~/state/entities";
3import { useLayoutPane, useLayoutPaneFocusedRef } from "../layout/layoutPaneContext";
4import { beginLayoutTabDrag } from "../layout/layoutDrag";
5import { useLayoutStore } from "../layout/layoutStore";
6import {
7 finishLayoutTabRename,
8 LayoutPaneTab,
9 LayoutPaneTabStrip,
10 showLayoutTabMenu,
11 tabMuteMenuItem,
12 useLayoutPaneSiblingTabs,
13 useLayoutTabLabel,
14 useLayoutTabRenameRequested,
15} from "../layout/PaneTabs";
16import { findLayoutTab, type LayoutTab } from "../layout/layoutModel";
17import type {
18 ContextMenuItem,
19 EnvironmentId,
20 PreviewSessionSnapshot,
21 ResolvedKeybindingsConfig,
22} from "@t3tools/contracts";
23import {
24 closestCenter,
25 DndContext,
26 type DragEndEvent,
27 PointerSensor,
28 useSensor,
29 useSensors,
30} from "@dnd-kit/core";
31import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers";
32import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
33import { CSS } from "@dnd-kit/utilities";
34import {
35 ChevronLeft,
36 ChevronRight,
37 EllipsisIcon,
38 Files,
39 Plus,
40 Volume2Icon,
41 VolumeOffIcon,
42} from "lucide-react";
43​
44import {
45 type KeyboardEvent as ReactKeyboardEvent,
46 type MouseEvent as ReactMouseEvent,
47 type ReactNode,
48 type Ref,
49 useRef,
50 useState,
51} from "react";
52​
53import { isElectron } from "~/env";
54import type { DesktopPreviewOverlay } from "~/previewStateStore";
55import type { RightPanelSurface } from "~/rightPanelStore";
56import { cn } from "~/lib/utils";
57import { readLocalApi } from "~/localApi";
58import { useStableCallback } from "~/hooks/useStableCallback";
59import { Button } from "~/components/ui/button";
60import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
61import { Menu, MenuPopup, MenuTrigger } from "~/components/ui/menu";
62import { useBrowserDefaults } from "~/browser/browserDefaults";
63import { ScrollArea } from "~/components/ui/scroll-area";
64import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
65import { useTheme } from "~/hooks/useTheme";
66import { useMediaQuery } from "~/hooks/useMediaQuery";
67import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
68import { useDeviceState } from "~/state/device";
69import type { PreviewPanelInlineSize } from "~/hooks/usePreviewPanelInlineSize";
70import { useRevealActiveTab } from "~/hooks/useRevealActiveTab";
71import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
72​
73import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";
74import { PanelTabSlotsContext, usePanelTabActionsHost } from "./PanelTabBreadcrumb";
75import {
76 PANE_BODY_UNDER_HEADER_CLASS,
77 PANE_INACTIVE_TAB_TITLE_CLASS,
78 PANE_TAB_TITLE_CLASS,
79 PaneNotch,
80 useConnectedPaneTabs,
81} from "./PaneNotch";
82import { previewBridge } from "./preview/previewBridge";
83import { dispatchPreviewAction } from "./preview/previewActionBus";
84import { preventThumbButtonFocus, takeThumbButtonNavigation } from "./preview/thumbButtons";
85import { observeResize } from "~/lib/observeResize";
86import {
87 previewFaviconOf,
88 SurfaceIcon,
89 surfaceTitle,
90 type PullRequestTabStatusSeed,
91} from "./SurfaceTabIcon";
92import {
93 SurfaceLauncherRows,
94 SurfaceMenuItems,
95 surfaceMenuActions,
96 surfaceShortcutActionForKey,
97 useLauncherElement,
98 useSurfaceLauncherKeys,
99 type SurfaceLauncherAction,
100} from "./SurfaceLauncher";
101​
102export {
103 surfaceShortcutActionForKey,
104 surfaceShortcutTargetsTypingContext,
105} from "./SurfaceLauncher";
106​
107export interface RightPanelTabsProps {
108 mode: PreviewPanelMode;
109 maximized?: boolean;
110 open?: boolean;
111 keybindings: ResolvedKeybindingsConfig;
112 widthStorageKey?: string;
113 defaultWidth?: number;
114 /**
115 * An inline panel drawn as its own inset pane, notch and all, beside a page's pane (the
116 * pull requests page's review, pen bETcv).
117 */
118 pane?: boolean;
119 inlineSize?: PreviewPanelInlineSize;
120 layoutControls?: ReactNode;
121 surfaces: readonly RightPanelSurface[];
122 environmentId: EnvironmentId | null;
123 activeSurfaceId: string | null;
124 pendingSurfaceIds: ReadonlySet<string>;
125 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;
126 desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>;
127 /** Maps server-local session ids to desktop runtime ids; session ids cannot address desktop operations. */
128 previewRuntimeTabId?: ((tabId: string) => string) | undefined;
129 terminalLabelsById: ReadonlyMap<string, string>;
130 onActivate: (surface: RightPanelSurface) => void;
131 onRenameDevice?: (surfaceId: string, title: string) => void;
132 onCloseSurface: (surface: RightPanelSurface) => void;
133 onCloseOtherSurfaces: (surface: RightPanelSurface) => void;
134 onCloseSurfacesToRight: (surface: RightPanelSurface) => void;
135 onCloseAllSurfaces: () => void;
136 onMoveSurface?: (surfaceId: string, toIndex: number) => void;
137 onCopyFilePath: (relativePath: string) => void;
138 /** Escape on the empty pane's chooser closes the pane; the sheet closes itself. */
139 onClose?: () => void;
140 onAddBrowser: () => void;
141 /** Keep separate from the DOM click handler so a MouseEvent cannot become a profile id. */
142 onAddBrowserInProfile: (profileId: string) => void;
143 /** The "+" menu's New thread, in the thread's project; omitted, it has none. */
144 onNewThread?: (() => void) | undefined;
145 onAddTerminal: () => void;
146 onAddDiff: () => void;
147 onAddFiles: () => void;
148 onAddPullRequest: () => void;
149 onAddPullRequests: () => void;
150 onAddDevice: () => void;
151 /** Absent where side chats are not offered. */
152 onAddSideChat?: (() => void) | undefined;
153 browserAvailable: boolean;
154 /** Why the browser cannot open, when it cannot. */
155 browserUnavailableReason?: string | null;
156 terminalAvailable: boolean;
157 diffAvailable: boolean;
158 filesAvailable: boolean;
159 pullRequestAvailable: boolean;
160 pullRequestsAvailable: boolean;
161 deviceAvailable: boolean;
162 sideChatAvailable?: boolean;
163 /** Overrides the default reason while side chats are available but cannot start. */
164 sideChatDisabledReason?: string | null;
165 pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>;
166 /** The linked thread stays in the tab's tooltip when its surface moves elsewhere. */
167 threadTitle?: string | null;
168 children: ReactNode;
170​
171const SURFACE_DISABLED_REASONS = {
172 browser: "Browser previews are only available in the T3 Code desktop app.",
173 terminal: "Terminal surfaces are only available from a project thread.",
174 files: "Files are only available when a project is open.",
175 diff: "Diff is only available for server threads in Git repositories.",
176 pullRequest: "This thread's branch has no pull request yet.",
177 pullRequests: "No linked pull requests are available for this thread.",
178 device: "Devices are only available from a thread.",
179 sideChat: "Side chats start after the first message.",
180} as const;
181​
182type TabContextMenuAction =
183 | "rename"
184 | "copy-path"
185 | "toggle-mute"
186 | "close"
187 | "close-others"
188 | "close-to-right"
189 | "close-all";
190​
191const TAB_SCROLL_EDGE_TOLERANCE = 1;
192const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor];
193​
194function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null {
195 return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null;
197​
198function previewTabIdOf(
199 surface: RightPanelSurface,
200 sessions: Readonly<Record<string, PreviewSessionSnapshot>>,
201): string | null {
202 if (surface.kind !== "preview" || !surface.resourceId) return null;
203 return sessions[surface.resourceId]?.tabId ?? null;
205​
206type TabAudioState = "none" | "audible" | "muted";
207​
208function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState {
209 if (!overlay?.audible) return "none";
210 return overlay.audioMuted ? "muted" : "audible";
212​
213/**
214 * A layout pane's tabs sit in its notch with "+" as the notch's last child,
215 * like a chat's and the terminal's; the sheet and inline panel keep a plain row.
216 */
217function PanelTabStrip({
218 notched,
219 ref,
220 children,
221}: {
222 notched: boolean;
223 ref: Ref<HTMLDivElement>;
224 children: ReactNode;
225}) {
226 if (notched) {
227 return (
228 <div
229 ref={ref}
230 className="flex shrink-0 items-center gap-1 max-md:min-w-0 max-md:shrink"
231 data-right-panel-tab-list
232 >
233 {children}
234 </div>
235 );
236 }
237 return (
238 <ScrollArea
239 radius="none"
240 ref={ref}
241 hideScrollbars
242 scrollFade
243 className="min-w-0 flex-1"
244 data-right-panel-tab-list
245 >
246 <div className="flex h-full w-max min-w-full items-center gap-1">{children}</div>
247 </ScrollArea>
248 );
250​
251function PanelTabsNotch(props: {
252 notched: boolean;
253 /** The active tab's breadcrumb slot, on the bar after the tabs (connected pane tabs). */
254 breadcrumb: ReactNode;
255 /** A pane's second strip, beside its own (the pull request beside its list). */
256 secondStrip: boolean;
257 addButton: ReactNode;
258 pinnedTab: ReactNode;
259 children: ReactNode;
260}) {
261 if (!props.notched) return props.children;
262 return (
263 <PaneNotch>
264 {props.pinnedTab}
265 <LayoutPaneTabStrip role={props.secondStrip ? "panel" : undefined}>
266 {props.children}
267 </LayoutPaneTabStrip>
268 {props.breadcrumb}
269 {props.addButton ? (
270 <span data-pane-notch-add="" className="flex shrink-0">
271 {props.addButton}
272 </span>
273 ) : null}
274 </PaneNotch>
275 );
277​
278function PanelActionsNotch(props: { notched: boolean; hasActions: boolean; children: ReactNode }) {
279 const isMobile = useMediaQuery("max-md");
280 if (!props.notched) return props.children;
281 if (!props.hasActions) return null;
282 if (isMobile) {
283 return (
284 <Popover>
285 <PopoverTrigger
286 render={<Button size="icon-sm" variant="ghost" aria-label="Surface controls" />}
287 >
288 <EllipsisIcon />
289 </PopoverTrigger>
290 <PopoverPopup align="end" padding="compact">
291 <div className="flex max-w-[calc(100vw-2rem)] flex-wrap items-center gap-2">
292 {props.children}
293 </div>
294 </PopoverPopup>
295 </Popover>
296 );
297 }
298 return (
299 <PaneNotch side="right" className="ms-auto shrink-0">
300 {props.children}
301 </PaneNotch>
302 );
304​
305/**
306 * Pane-wide letter shortcuts must yield to typing contexts.
307 * The focused launcher also supports arrows and explains unavailable surfaces.
308 */
309function RightPanelEmptyState(props: {
310 onClose: (() => void) | undefined;
311 onAddBrowser: () => void;
312 onAddBrowserInProfile: (profileId: string) => void;
313 browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>;
314 onAddTerminal: () => void;
315 onAddDiff: () => void;
316 onAddFiles: () => void;
317 onAddPullRequest: () => void;
318 onAddPullRequests: () => void;
319 onAddDevice: () => void;
320 /** Absent where side chats are not offered. */
321 onAddSideChat?: (() => void) | undefined;
322 browserAvailable: boolean;
323 browserUnavailableReason: string | null | undefined;
324 terminalAvailable: boolean;
325 diffAvailable: boolean;
326 filesAvailable: boolean;
327 pullRequestAvailable: boolean;
328 pullRequestsAvailable: boolean;
329 deviceAvailable: boolean;
330 sideChatAvailable: boolean;
331 sideChatDisabledReason: string | null;
332}) {
333 const actions: SurfaceLauncherAction[] = [
334 {
335 kind: "browser",
336 shortcut: "B",
337 available: props.browserAvailable,
338 ...(props.browserUnavailableReason ? { disabledReason: props.browserUnavailableReason } : {}),
339 onClick: props.onAddBrowser,
340 },
341 {
342 kind: "terminal",
343 shortcut: "T",
344 available: props.terminalAvailable,
345 onClick: props.onAddTerminal,
346 },
347 { kind: "files", shortcut: "F", available: props.filesAvailable, onClick: props.onAddFiles },
348 { kind: "diff", shortcut: "D", available: props.diffAvailable, onClick: props.onAddDiff },
349 {
350 kind: "pull-request",
351 shortcut: "P",
352 available: props.pullRequestAvailable,
353 onClick: props.onAddPullRequest,
354 },
355 {
356 kind: "pull-requests",
357 shortcut: "L",
358 available: props.pullRequestsAvailable,
359 onClick: props.onAddPullRequests,
360 },
361 { kind: "device", shortcut: "M", available: props.deviceAvailable, onClick: props.onAddDevice },
362 ...(props.onAddSideChat
363 ? [
364 {
365 kind: "side-chat" as const,
366 shortcut: "S",
367 available: props.sideChatAvailable && !props.sideChatDisabledReason,
368 onClick: props.onAddSideChat,
369 },
370 ]
371 : []),
372 ];
373 const paneFocusedRef = useLayoutPaneFocusedRef();
374 const [launcherRef, attachLauncher] = useLauncherElement(paneFocusedRef, false);
375 useSurfaceLauncherKeys({
376 launcherRef,
377 activeRef: paneFocusedRef,
378 actions,
379 onClose: props.onClose,
380 });
381​
382 return (
383 <div
384 data-workspace-focus-default
385 ref={attachLauncher}
386 tabIndex={0}
387 aria-label="Open a surface"
388 data-surface-launcher-keys={actions
389 .filter((action) => action.available)
390 .map((action) => action.shortcut)
391 .join("")}
392 className={cn(
393 "scrollbar-gutter-both flex min-h-0 flex-1 items-center-safe justify-center overflow-y-auto px-4 outline-none",
394 "pb-(--workspace-topbar-height)",
395 )}
396 >
397 <div
398 role="list"
399 aria-label="Surfaces"
400 className="flex w-full max-w-110 flex-col gap-0.5 py-6"
401 >
402 <SurfaceLauncherRows
403 actions={actions}
404 browserProfiles={props.browserProfiles}
405 onAddBrowserInProfile={props.onAddBrowserInProfile}
406 />
407 </div>
408 </div>
409 );
411​
412function SortableTabList(props: {
413 surfaces: readonly RightPanelSurface[];
414 onMove: ((surfaceId: string, toIndex: number) => void) | undefined;
415 children: ReactNode;
416}) {
417 const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
418 const surfaceIds = props.surfaces.map((surface) => surface.id);
419 const { onMove, surfaces } = props;
420 const handleDragEnd = ({ active, over }: DragEndEvent) => {
421 if (!over || active.id === over.id) return;
422 const toIndex = surfaces.findIndex((surface) => surface.id === over.id);
423 if (toIndex >= 0) onMove?.(String(active.id), toIndex);
424 };
425 return (
426 <DndContext
427 sensors={sensors}
428 collisionDetection={closestCenter}
429 modifiers={TAB_DRAG_MODIFIERS}
430 onDragEnd={handleDragEnd}
431 >
432 <SortableContext items={surfaceIds} strategy={horizontalListSortingStrategy}>
433 {props.children}
434 </SortableContext>
435 </DndContext>
436 );
438​
439interface RightPanelTabHandlers {
440 onActivate: (surface: RightPanelSurface) => void;
441 onClose: (surface: RightPanelSurface) => void;
442 onAuxClick: (event: ReactMouseEvent, surface: RightPanelSurface) => void;
443 onContextMenu: (event: ReactMouseEvent, surface: RightPanelSurface) => void;
444 onStartRename: (surfaceId: string) => void;
445 onFinishRename: (surfaceId: string, title: string) => void;
447​
448const NO_LAYOUT_TAB: LayoutTab = {
449 id: "panel",
450 content: { kind: "launcher", id: "panel", context: null },
451};
452​
453// Chromium on Linux only suppresses primary-selection paste when mouseup is cancelled.
454function preventTabMiddleButtonDefault(event: ReactMouseEvent) {
455 if (event.button !== 1) return;
456 event.preventDefault();
458​
459// Stable handlers and per-tab values let the compiler reuse tabs across parent renders.
460function RightPanelTab(
461 props: RightPanelTabHandlers & {
462 surface: RightPanelSurface;
463 dragDisabled: boolean;
464 active: boolean;
465 pending: boolean;
466 title: string;
467 audio: TabAudioState;
468 audioRuntimeTabId: string | null;
469 previewUrl: string | null;
470 previewCapturedUrl: string | null;
471 theme: "light" | "dark";
472 environmentId: EnvironmentId | null;
473 pullRequestStatusSeed: PullRequestTabStatusSeed | undefined;
474 renaming: boolean;
475 canRename: boolean;
476 ownsDesktopTitleBar: boolean;
477 notched: boolean;
478 layoutTabId: string | null;
479 threadTitle: string | null;
480 onBreadcrumbSlotChange: (element: HTMLSpanElement | null) => void;
481 /** The breadcrumb sits on the pane's bar, outside the tab. */
482 breadcrumbOnBar: boolean;
483 },
484) {
485 const {
486 dragDisabled,
487 previewUrl,
488 previewCapturedUrl,
489 theme,
490 environmentId,
491 pullRequestStatusSeed,
492 renaming,
493 canRename,
494 threadTitle,
495 onBreadcrumbSlotChange,
496 breadcrumbOnBar,
497 surface,
498 audio,
499 audioRuntimeTabId,
500 active,
501 pending,
502 notched,
503 layoutTabId,
504 ownsDesktopTitleBar,
505 onActivate,
506 onClose,
507 onAuxClick,
508 onContextMenu,
509 onStartRename,
510 onFinishRename,
511 } = props;
512 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
513 id: surface.id,
514 disabled: dragDisabled,
515 });
516 const sideChatShell = useQuietThreadShell(
517 surface.kind === "side-chat" && props.environmentId !== null
518 ? scopeThreadRef(props.environmentId, surface.threadId)
519 : null,
520 );
521 const layoutTab = useLayoutStore((state) =>
522 layoutTabId === null
523 ? null
524 : (findLayoutTab(state.layout, (tab) => tab.id === layoutTabId)?.tab ?? null),
525 );
526 const layoutLabel = useLayoutTabLabel(layoutTab ?? NO_LAYOUT_TAB);
527 const title = layoutTab ? layoutLabel.title : (sideChatShell?.title ?? props.title);
528 const tooltip = layoutTab
529 ? layoutLabel.tooltip
530 : [title, threadTitle, previewUrl, surface.kind === "file" ? surface.relativePath : null]
531 .filter(Boolean)
532 .join("\n");
533 // An open workspace file reads "Files › file.tsx": its breadcrumb names it.
534 const filesCrumb = active && surface.kind === "file" && !surface.attachment;
535 return (
536 <div
537 ref={setNodeRef}
538 style={{ transform: CSS.Translate.toString(transform), transition }}
539 data-active-tab={active}
540 data-tab-key={surface.id}
541 data-layout-pane-tab={layoutTabId ?? undefined}
542 data-layout-tab-reveal={filesCrumb ? "end" : undefined}
543 onPointerDown={
544 layoutTabId !== null ? (event) => beginLayoutTabDrag(event, layoutTabId) : undefined
545 }
546 onMouseDown={preventTabMiddleButtonDefault}
547 onMouseUp={preventTabMiddleButtonDefault}
548 onAuxClick={(event) => onAuxClick(event, surface)}
549 onContextMenu={(event) => onContextMenu(event, surface)}
550 {...listeners}
551 className={cn(
552 "cursor-pointer group/tab flex shrink-0 items-center",
553 notched && "max-md:min-w-0 max-md:shrink",
554 isDragging && "relative z-10",
555 // In the notch a tab is the chat's 28px notch tab (pen JiaZk).
556 notched
557 ? "h-7 gap-1.75 rounded-control px-2.5 text-label"
558 : "h-6 gap-0.5 rounded-item pr-2 pl-1.5 text-xs",
559 // The active tab grows with its breadcrumb; its title caps below.
560 !active && !notched && "max-w-36",
561 ownsDesktopTitleBar && "[-webkit-app-region:no-drag]",
562 active
563 ? cn("text-strong-foreground", !notched && "font-medium")
564 : "text-muted-foreground hover:surface-ghost-hover hover:text-foreground",
565 )}
566 >
567 <>
568 <PanelTabCloseButton
569 label={`Close ${title}`}
570 onClick={() => onClose(surface)}
571 showOnPhone={notched}
572 >
573 {filesCrumb ? (
574 <Files className="size-3.5 shrink-0 text-foreground" />
575 ) : surface.kind === "terminal" && layoutTab ? (
576 layoutLabel.icon
577 ) : (
578 <SurfaceIcon
579 surface={surface}
580 previewUrl={previewUrl}
581 previewCapturedUrl={previewCapturedUrl}
582 theme={theme}
583 environmentId={environmentId}
584 pullRequestStatusSeed={pullRequestStatusSeed}
585 />
586 )}
587 {pending ? (
588 <span
589 className="absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full bg-current"
590 aria-hidden
591 />
592 ) : null}
593 </PanelTabCloseButton>
594 {audio === "none" || !audioRuntimeTabId ? null : (
595 <Tooltip>
596 <TooltipTrigger
597 render={
598 <button
599 type="button"
600 className="cursor-pointer flex size-4 shrink-0 items-center justify-center rounded-kbd hover:surface-ghost-hover"
601 aria-label={audio === "muted" ? `Unmute ${title}` : `Mute ${title}`}
602 onClick={(event) => {
603 event.stopPropagation();
604 void previewBridge
605 ?.setAudioMuted(audioRuntimeTabId, audio !== "muted")
606 .catch(() => undefined);
607 }}
608 >
609 {audio === "muted" ? (
610 <VolumeOffIcon className="size-3" />
611 ) : (
612 <Volume2Icon className="size-3" />
613 )}
614 </button>
615 }
616 />
617 <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup>
618 </Tooltip>
619 )}
620 {renaming ? (
621 <input
622 aria-label="Device tab name"
623 className="w-24 min-w-0 rounded-kbd surface-well px-1 outline-none focus:surface-focus"
624 defaultValue={title}
625 ref={(element) => {
626 element?.focus();
627 element?.select();
628 }}
629 onBlur={(event) => onFinishRename(surface.id, event.currentTarget.value)}
630 onKeyDown={(event) => {
631 event.stopPropagation();
632 if (event.key === "Enter") event.currentTarget.blur();
633 if (event.key === "Escape") {
634 event.currentTarget.value = title;
635 event.currentTarget.blur();
636 }
637 }}
638 />
639 ) : (
640 <Tooltip>
641 <TooltipTrigger
642 render={
643 <button
644 type="button"
645 onDoubleClick={() => {
646 if (surface.kind === "device" && canRename) onStartRename(surface.id);
647 }}
648 className={cn(
649 "cursor-pointer flex min-w-0 items-center outline-none focus-visible:inset-ring-2 focus-visible:inset-ring-ring",
650 filesCrumb && notched && "max-md:hidden",
651 active && surface.kind === "diff" && "shrink-0",
652 )}
653 aria-label={
654 surface.kind === "terminal" && threadTitle
655 ? `${title}, ${threadTitle}`
656 : undefined
657 }
658 onClick={() => onActivate(surface)}
659 >
660 <span
661 className={
662 notched
663 ? active
664 ? PANE_TAB_TITLE_CLASS
665 : PANE_INACTIVE_TAB_TITLE_CLASS
666 : cn("truncate", active && "max-w-60")
667 }
668 >
669 {filesCrumb ? "Files" : title}
670 </span>
671 </button>
672 }
673 />
674 <TooltipPopup className="whitespace-pre-line">
675 {surface.kind === "device" ? (
676 <>
677 {threadTitle ? (
678 <>
679 {threadTitle}
680 <br />
681 </>
682 ) : null}
683 <DeviceTabTooltip surface={surface} environmentId={environmentId} title={title} />
684 </>
685 ) : (
686 tooltip
687 )}
688 </TooltipPopup>
689 </Tooltip>
690 )}
691 {active && !breadcrumbOnBar ? (
692 <span
693 ref={onBreadcrumbSlotChange}
694 className={cn(
695 "flex min-w-0 items-center empty:hidden",
696 filesCrumb && "min-w-[8ch] max-md:min-w-0",
697 filesCrumb && notched && "max-md:[&>svg:first-child]:hidden",
698 notched ? "gap-1" : "gap-0.5",
699 )}
700 />
701 ) : null}
702 </>
703 </div>
704 );
706​
707const PANE_BODY_SURFACE_CLASS: Partial<Record<RightPanelSurface["kind"], string>> = {
708 "pull-request": "bg-(--code-surface)",
709 "pull-requests": "bg-transparent",
710 file: "bg-(--code-surface)",
711 terminal: "bg-(--terminal-background)",
712};
713​
714export function RightPanelTabs(props: RightPanelTabsProps) {
715 const focusedSurface = props.surfaces.find((surface) => surface.id === props.activeSurfaceId);
716 const ownsDesktopTitleBar = isElectron && props.mode === "inline";
717 const browserProfiles = useBrowserDefaults().profiles;
718 const { resolvedTheme } = useTheme();
719 const tabListRef = useRef<HTMLDivElement>(null);
720 // Embedded, this is one surface's tab in the layout: it drags like any tab
721 // and its pane's other tabs sit around it in the notch.
722 const { tabId: layoutPaneTabId, leadingEdge } = useLayoutPane();
723 const layoutTabId = props.mode === "embedded" ? layoutPaneTabId : null;
724 const siblingTabs = useLayoutPaneSiblingTabs();
725 const isMobile = useMediaQuery("max-md");
726 const pinnedChatTab =
727 isMobile && layoutTabId !== null
728 ? [...siblingTabs.before, ...siblingTabs.after].find((tab) => tab.content.kind === "chat")
729 : undefined;
730 const [renamingDevice, setRenamingDevice] = useState<string | null>(null);
731 const renameRequested = useLayoutTabRenameRequested(layoutTabId);
732 const renamingDeviceId = renamingDevice ?? (renameRequested ? props.activeSurfaceId : null);
733 // The active tab's breadcrumb and the row's actions slot; its surface
734 // portals its header into them (PanelTabBreadcrumb, PanelTabActions).
735 const [breadcrumbSlot, setBreadcrumbSlot] = useState<HTMLSpanElement | null>(null);
736 const [mobileBreadcrumbSlot, setMobileBreadcrumbSlot] = useState<HTMLSpanElement | null>(null);
737 const [actionsSlot, setActionsSlot] = useState<HTMLDivElement | null>(null);
738 const { hasActions, registerActions } = usePanelTabActionsHost();
739 const mobileDiff = isMobile && props.mode === "embedded" && focusedSurface?.kind === "diff";
740 // A layout pane's tabs sit in its notch (pen JiaZk), like a chat's.
741 const notched = props.mode === "embedded" || props.pane === true;
742 // On the connected bar the tab names its kind and its breadcrumb sits beside it.
743 const breadcrumbOnBar = useConnectedPaneTabs() && notched;
744 const tabSlots = {
745 breadcrumb: mobileDiff ? mobileBreadcrumbSlot : breadcrumbSlot,
746 actions: actionsSlot,
747 registerActions,
748 breadcrumbOnBar,
749 };
750 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);
751 const [tabScrollState, setTabScrollState] = useState({
752 hasOverflow: false,
753 canScrollLeft: false,
754 canScrollRight: false,
755 });
756​
757 if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false);
758​
759 const updateTabScrollState = (viewport: HTMLDivElement) => {
760 const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE;
761 const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE;
762 const canScrollRight =
763 hasOverflow &&
764 viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - TAB_SCROLL_EDGE_TOLERANCE;
765 setTabScrollState((current) => {
766 if (
767 current.hasOverflow === hasOverflow &&
768 current.canScrollLeft === canScrollLeft &&
769 current.canScrollRight === canScrollRight
770 ) {
771 return current;
772 }
773 return { hasOverflow, canScrollLeft, canScrollRight };
774 });
775 };
776​
777 const scrollTabs = (direction: -1 | 1) => {
778 const viewport = tabScrollViewport(tabListRef.current);
779 if (!viewport) return;
780 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
781 viewport.scrollBy({
782 left: direction * Math.max(120, viewport.clientWidth * 0.75),
783 behavior: reduceMotion ? "auto" : "smooth",
784 });
785 };
786​
787 const addSurfaceActions = surfaceMenuActions({
788 onNewThread: props.onNewThread,
789 surfaces: {
790 browser: {
791 available: props.browserAvailable,
792 disabledReason: props.browserUnavailableReason ?? SURFACE_DISABLED_REASONS.browser,
793 onClick: props.onAddBrowser,
794 },
795 terminal: {
796 available: props.terminalAvailable,
797 disabledReason: SURFACE_DISABLED_REASONS.terminal,
798 onClick: props.onAddTerminal,
799 },
800 files: {
801 available: props.filesAvailable,
802 disabledReason: SURFACE_DISABLED_REASONS.files,
803 onClick: props.onAddFiles,
804 },
805 diff: {
806 available: props.diffAvailable,
807 disabledReason: SURFACE_DISABLED_REASONS.diff,
808 onClick: props.onAddDiff,
809 },
810 "pull-request": {
811 available: props.pullRequestAvailable,
812 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,
813 onClick: props.onAddPullRequest,
814 },
815 "pull-requests": {
816 available: props.pullRequestsAvailable,
817 disabledReason: SURFACE_DISABLED_REASONS.pullRequests,
818 onClick: props.onAddPullRequests,
819 },
820 device: {
821 available: props.deviceAvailable,
822 disabledReason: SURFACE_DISABLED_REASONS.device,
823 onClick: props.onAddDevice,
824 },
825 ...(props.onAddSideChat
826 ? {
827 "side-chat": {
828 available: props.sideChatAvailable === true && !props.sideChatDisabledReason,
829 disabledReason: props.sideChatDisabledReason ?? SURFACE_DISABLED_REASONS.sideChat,
830 onClick: props.onAddSideChat,
831 },
832 }
833 : {}),
834 },
835 });
836​
837 const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
838 const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent);
839 if (!action) return;
840 event.preventDefault();
841 event.stopPropagation();
842 setAddSurfaceMenuOpen(false);
843 action.onClick();
844 };
845​
846 const handleTabContextMenu = async (event: ReactMouseEvent, surface: RightPanelSurface) => {
847 event.preventDefault();
848 event.stopPropagation();
849​
850 if (layoutTabId !== null) {
851 void showLayoutTabMenu(
852 layoutTabId,
853 { x: event.clientX, y: event.clientY },
854 props.keybindings,
855 () => setRenamingDevice(surface.id),
856 );
857 return;
858 }
859​
860 const api = readLocalApi();
861 if (!api) return;
862​
863 const surfaceIndex = props.surfaces.findIndex((entry) => entry.id === surface.id);
864 if (surfaceIndex < 0) return;
865​
866 const items: ContextMenuItem<TabContextMenuAction>[] = [];
867 if (surface.kind === "device" && props.onRenameDevice)
868 items.push({ id: "rename", label: "Rename" });
869 if (surface.kind === "file" && surface.attachment === undefined) {
870 items.push({ id: "copy-path", label: "Copy path" });
871 }
872 const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions);
873 const menuOverlay = menuPreviewTabId ? (props.desktopByTabId[menuPreviewTabId] ?? null) : null;
874 const menuMuted = menuOverlay?.audioMuted ?? false;
875 if (surface.kind === "preview") {
876 items.push({
877 id: "toggle-mute",
878 ...tabMuteMenuItem({
879 overlay: menuOverlay,
880 canResolveRuntimeTabId: props.previewRuntimeTabId !== undefined,
881 }),
882 });
883 }
884 items.push(
885 { id: "close", label: "Close" },
886 {
887 id: "close-others",
888 label: "Close others",
889 disabled: props.surfaces.length <= 1,
890 },
891 {
892 id: "close-to-right",
893 label: "Close to the right",
894 disabled: surfaceIndex >= props.surfaces.length - 1,
895 },
896 {
897 id: "close-all",
898 label: "Close all",
899 disabled: props.surfaces.length === 0,
900 },
901 );
902​
903 const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY });
904 switch (action) {
905 case "rename":
906 setRenamingDevice(surface.id);
907 break;
908 case "copy-path":
909 if (surface.kind === "file" && surface.attachment === undefined) {
910 props.onCopyFilePath(surface.relativePath);
911 }
912 break;
913 case "toggle-mute": {
914 const runtimeTabId =
915 menuPreviewTabId && menuOverlay
916 ? (props.previewRuntimeTabId?.(menuPreviewTabId) ?? null)
917 : null;
918 if (runtimeTabId) {
919 void previewBridge?.setAudioMuted(runtimeTabId, !menuMuted).catch(() => undefined);
920 }
921 break;
922 }
923 case "close":
924 props.onCloseSurface(surface);
925 break;
926 case "close-others":
927 props.onCloseOtherSurfaces(surface);
928 break;
929 case "close-to-right":
930 props.onCloseSurfacesToRight(surface);
931 break;
932 case "close-all":
933 props.onCloseAllSurfaces();
934 break;
935 case null:
936 break;
937 }
938 };
939 const handleTabAuxClick = (event: ReactMouseEvent, surface: RightPanelSurface) => {
940 if (event.button !== 1) return;
941 event.preventDefault();
942 event.stopPropagation();
943 props.onCloseSurface(surface);
944 };
945 const onActivateTab = useStableCallback(props.onActivate);
946 const onCloseTab = useStableCallback(props.onCloseSurface);
947 const onTabAuxClick = useStableCallback(handleTabAuxClick);
948 const onTabContextMenu = useStableCallback(
949 (event: ReactMouseEvent, surface: RightPanelSurface) => {
950 void handleTabContextMenu(event, surface);
951 },
952 );
953 const onStartRenameTab = useStableCallback((surfaceId: string) => setRenamingDevice(surfaceId));
954 const onFinishRenameTab = useStableCallback((surfaceId: string, title: string) => {
955 props.onRenameDevice?.(surfaceId, title);
956 setRenamingDevice(null);
957 finishLayoutTabRename(layoutTabId);
958 });
959​
960 useRevealActiveTab(tabListRef, layoutTabId === null ? props.activeSurfaceId : null);
961​
962 const attachTabList = (root: HTMLDivElement | null) => {
963 tabListRef.current = root;
964 const viewport = tabScrollViewport(root);
965 if (!viewport) return;
966​
967 const update = () => updateTabScrollState(viewport);
968 // Only a vertical mouse wheel is translated. Horizontal input scrolls natively, since
969 // consuming it keeps Chromium from starting the gesture scroll that swipe navigation tracks.
970 const handleWheel = (event: WheelEvent) => {
971 if (event.ctrlKey || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return;
972 let delta = event.deltaY;
973 if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16;
974 if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= viewport.clientWidth;
975​
976 const previousScrollLeft = viewport.scrollLeft;
977 viewport.scrollLeft += delta;
978 if (viewport.scrollLeft === previousScrollLeft) return;
979 event.preventDefault();
980 update();
981 };
982​
983 const content = viewport.firstElementChild;
984 const stopObserving = observeResize(content ? [viewport, content] : viewport, update);
985 viewport.addEventListener("scroll", update, { passive: true });
986 viewport.addEventListener("wheel", handleWheel, { passive: false });
987 update();
988​
989 return () => {
990 tabListRef.current = null;
991 stopObserving();
992 viewport.removeEventListener("scroll", update);
993 viewport.removeEventListener("wheel", handleWheel);
994 };
995 };
996​
997 const addSurfaceMenu =
998 // A notch keeps it while empty too: an empty notch reads as a broken corner.
999 props.open !== false || props.surfaces.length > 0 || notched ? (
1000 <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}>
1001 <MenuTrigger
1002 render={
1003 <Button
1004 aria-label="Add panel surface"
1005 className="shrink-0"
1006 size="icon-xs"
1007 variant="ghost-muted"
1008 />
1011 <Plus className="size-3.5" />
1012 </MenuTrigger>
1013 <MenuPopup
1014 align="start"
1015 side="bottom"
1016 sideOffset={6}
1017 onKeyDownCapture={handleAddSurfaceMenuKeyDown}
1019 <SurfaceMenuItems
1020 actions={addSurfaceActions}
1021 browserProfiles={browserProfiles}
1022 onAddBrowserInProfile={props.onAddBrowserInProfile}
1023 onClose={() => setAddSurfaceMenuOpen(false)}
1024 />
1025 </MenuPopup>
1026 </Menu>
1027 ) : null;
1028​
1029 return (
1030 <PreviewPanelShell
1031 workspaceArea
1032 workspaceFocusKey={`panel:${props.environmentId}:${props.activeSurfaceId ?? "empty"}`}
1033 workspaceLabel={
1034 focusedSurface
1035 ? `Workspace panel: ${surfaceTitle(focusedSurface, props.previewSessions, props.terminalLabelsById)}`
1036 : "Workspace panel"
1038 mode={props.mode}
1039 {...(props.maximized !== undefined ? { maximized: props.maximized } : {})}
1040 {...(props.open !== undefined ? { open: props.open } : {})}
1041 {...(props.widthStorageKey !== undefined ? { widthStorageKey: props.widthStorageKey } : {})}
1042 {...(props.defaultWidth !== undefined ? { defaultWidth: props.defaultWidth } : {})}
1043 {...(props.pane ? { pane: true } : {})}
1044 {...(props.inlineSize ? { inlineSize: props.inlineSize } : {})}
1045 onMouseDown={preventThumbButtonFocus}
1046 onMouseUp={(event) =>
1047 takeThumbButtonNavigation(
1048 event,
1049 focusedSurface?.kind === "preview" ? dispatchPreviewAction : undefined,
1053 <div
1054 className={cn(
1055 // diff-toolbar: the active surface's actions sit in this row and fold
1056 // into a menu when the pane is narrow (DiffViewControls).
1057 "@container/diff-toolbar flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-1",
1058 // A layout pane's row lines up with its chat's header and starts at
1059 // the pane's edge, under its notch.
1060 notched
1061 ? cn(
1062 "relative z-10 ps-1.5 pe-2 md:ps-0",
1063 (focusedSurface && PANE_BODY_SURFACE_CLASS[focusedSurface.kind]) ?? "bg-background",
1065 : "pl-2",
1066 !notched && (props.mode === "inline" && !props.layoutControls ? "pr-28" : "pr-3"),
1067 ownsDesktopTitleBar && "drag-region",
1068 ownsDesktopTitleBar && "wco:pr-(--workspace-native-controls-inset)",
1069 props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
1070 // The leading embedded pane shares the mobile row with the shell toggle.
1071 notched && leadingEdge && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
1072 )}
1073 data-right-panel-tabbar
1074 data-pane-header={notched ? "" : undefined}
1076 <PanelTabsNotch
1077 notched={notched}
1078 breadcrumb={
1079 breadcrumbOnBar ? (
1080 <span
1081 ref={setBreadcrumbSlot}
1082 data-pane-bar-crumb=""
1083 className="flex min-w-0 shrink-100 items-center gap-1 ps-1 empty:hidden"
1084 />
1085 ) : null
1087 secondStrip={props.mode !== "embedded"}
1088 addButton={addSurfaceMenu}
1089 pinnedTab={
1090 pinnedChatTab ? (
1091 <span className="flex max-w-24 min-w-0 shrink-0">
1092 <LayoutPaneTab tab={pinnedChatTab} />
1093 </span>
1094 ) : null
1097 {layoutTabId !== null
1098 ? siblingTabs.before
1099 .filter((tab) => tab.id !== pinnedChatTab?.id)
1100 .map((tab) => <LayoutPaneTab key={tab.id} tab={tab} />)
1101 : null}
1102 <PanelTabStrip notched={notched} ref={attachTabList}>
1103 <SortableTabList surfaces={props.surfaces} onMove={props.onMoveSurface}>
1104 {props.surfaces.map((surface) => {
1105 const previewTabId = previewTabIdOf(surface, props.previewSessions);
1106 const favicon = previewFaviconOf(
1107 surface,
1108 props.previewSessions,
1109 props.desktopByTabId,
1110 );
1111 return (
1112 <RightPanelTab
1113 key={surface.id}
1114 surface={surface}
1115 // A layout tab's strip drags the whole tab instead.
1116 dragDisabled={
1117 !props.onMoveSurface ||
1118 renamingDeviceId === surface.id ||
1119 layoutTabId !== null
1121 active={surface.id === props.activeSurfaceId}
1122 pending={props.pendingSurfaceIds.has(surface.id)}
1123 title={surfaceTitle(surface, props.previewSessions, props.terminalLabelsById)}
1124 audio={tabAudioState(
1125 previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null,
1126 )}
1127 audioRuntimeTabId={
1128 previewTabId ? (props.previewRuntimeTabId?.(previewTabId) ?? null) : null
1130 previewUrl={favicon.url}
1131 previewCapturedUrl={favicon.capturedUrl}
1132 theme={resolvedTheme}
1133 environmentId={props.environmentId}
1134 pullRequestStatusSeed={props.pullRequestStatusSeeds?.[surface.id]}
1135 renaming={renamingDeviceId === surface.id}
1136 canRename={props.onRenameDevice !== undefined}
1137 ownsDesktopTitleBar={ownsDesktopTitleBar}
1138 notched={notched}
1139 layoutTabId={layoutTabId}
1140 threadTitle={props.threadTitle ?? null}
1141 onBreadcrumbSlotChange={setBreadcrumbSlot}
1142 breadcrumbOnBar={breadcrumbOnBar}
1143 onActivate={onActivateTab}
1144 onClose={onCloseTab}
1145 onAuxClick={onTabAuxClick}
1146 onContextMenu={onTabContextMenu}
1147 onStartRename={onStartRenameTab}
1148 onFinishRename={onFinishRenameTab}
1149 />
1150 );
1151 })}
1152 </SortableTabList>
1153 {notched ? null : addSurfaceMenu}
1154 </PanelTabStrip>
1155 {layoutTabId !== null
1156 ? siblingTabs.after
1157 .filter((tab) => tab.id !== pinnedChatTab?.id)
1158 .map((tab) => <LayoutPaneTab key={tab.id} tab={tab} />)
1159 : null}
1160 </PanelTabsNotch>
1161 {/* In the notch the tabs scroll by wheel and ctrl+Tab; the actions need the room. */}
1162 {!notched && tabScrollState.hasOverflow ? (
1163 <div
1164 className="flex shrink-0 items-center gap-0.5 [-webkit-app-region:no-drag]"
1165 role="group"
1166 aria-label="Scroll panel tabs"
1168 <Tooltip>
1169 <TooltipTrigger
1170 render={
1171 <span className="inline-flex">
1172 <Button
1173 aria-label="Scroll tabs left"
1174 disabled={!tabScrollState.canScrollLeft}
1175 onClick={() => scrollTabs(-1)}
1176 size="icon-xs"
1177 variant="ghost"
1179 <ChevronLeft />
1180 </Button>
1181 </span>
1183 />
1184 <TooltipPopup>Scroll tabs left</TooltipPopup>
1185 </Tooltip>
1186 <Tooltip>
1187 <TooltipTrigger
1188 render={
1189 <span className="inline-flex">
1190 <Button
1191 aria-label="Scroll tabs right"
1192 disabled={!tabScrollState.canScrollRight}
1193 onClick={() => scrollTabs(1)}
1194 size="icon-xs"
1195 variant="ghost"
1197 <ChevronRight />
1198 </Button>
1199 </span>
1201 />
1202 <TooltipPopup>Scroll tabs right</TooltipPopup>
1203 </Tooltip>
1204 </div>
1205 ) : null}
1206 <PanelActionsNotch
1207 notched={notched && props.activeSurfaceId !== null}
1208 hasActions={hasActions || props.layoutControls !== undefined}
1210 {mobileDiff ? (
1211 <span ref={setMobileBreadcrumbSlot} className="flex min-w-0 items-center gap-1" />
1212 ) : null}
1213 <div
1214 ref={setActionsSlot}
1215 className="ms-auto flex shrink-0 items-center gap-0.5 empty:hidden [-webkit-app-region:no-drag]"
1216 data-right-panel-tab-actions
1217 />
1218 {props.layoutControls}
1219 </PanelActionsNotch>
1220 {ownsDesktopTitleBar && !props.layoutControls ? (
1221 <span aria-hidden className="hidden w-24 shrink-0 wco:block" />
1222 ) : null}
1223 {ownsDesktopTitleBar ? (
1224 <span
1225 aria-hidden
1226 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]"
1227 />
1228 ) : null}
1229 </div>
1230 <div
1231 className={cn("flex min-h-0 flex-1 flex-col", notched && PANE_BODY_UNDER_HEADER_CLASS)}
1232 data-right-panel-surface-content
1233 data-right-panel-surface-kind={focusedSurface?.kind}
1235 {props.activeSurfaceId === null ? (
1236 <RightPanelEmptyState
1237 onClose={props.onClose}
1238 onAddBrowser={props.onAddBrowser}
1239 onAddBrowserInProfile={props.onAddBrowserInProfile}
1240 browserProfiles={browserProfiles}
1241 onAddTerminal={props.onAddTerminal}
1242 onAddDiff={props.onAddDiff}
1243 onAddFiles={props.onAddFiles}
1244 onAddPullRequest={props.onAddPullRequest}
1245 onAddPullRequests={props.onAddPullRequests}
1246 onAddDevice={props.onAddDevice}
1247 onAddSideChat={props.onAddSideChat}
1248 browserAvailable={props.browserAvailable}
1249 browserUnavailableReason={props.browserUnavailableReason}
1250 terminalAvailable={props.terminalAvailable}
1251 diffAvailable={props.diffAvailable}
1252 filesAvailable={props.filesAvailable}
1253 pullRequestAvailable={props.pullRequestAvailable}
1254 pullRequestsAvailable={props.pullRequestsAvailable}
1255 deviceAvailable={props.deviceAvailable}
1256 sideChatAvailable={props.sideChatAvailable === true}
1257 sideChatDisabledReason={props.sideChatDisabledReason ?? null}
1258 />
1259 ) : (
1260 <PanelTabSlotsContext value={tabSlots}>{props.children}</PanelTabSlotsContext>
1261 )}
1262 </div>
1263 </PreviewPanelShell>
1264 );
1266​
1267function DeviceTabTooltip(props: {
1268 surface: Extract<RightPanelSurface, { kind: "device" }>;
1269 environmentId: EnvironmentId | null;
1270 title: string;
1271}) {
1272 const target = props.surface.target;
1273 const { state } = useDeviceState(target ? props.environmentId : null);
1274 const device = target
1275 ? state.devices.find((entry) => entry.hostId === target.hostId && entry.id === target.deviceId)
1276 : undefined;
1277 const host = target ? state.hosts.find((entry) => entry.id === target.hostId) : undefined;
1278 return (
1279 <div className="flex flex-col gap-0.5">
1280 <span>{props.title}</span>
1281 {target ? (
1282 <span className="flex flex-col gap-0.5 text-muted-foreground">
1283 <span>{host?.label ?? "Device host"}</span>
1284 <span>{device?.version ?? (target.platform === "ios" ? "iOS" : "Android")}</span>
1285 </span>
1286 ) : null}
1287 </div>
1288 );