import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, useCallback, useEffect, useRef, useState, } from "react"; import { isElectron } from "~/env"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { ScrollArea } from "~/components/ui/scroll-area"; import { faviconUrlForOrigin } from "~/lib/favicon"; import { useTheme } from "~/hooks/useTheme"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; layoutControls?: ReactNode; surfaces: readonly RightPanelSurface[]; activeSurfaceId: string | null; pendingSurfaceIds: ReadonlySet; previewSessions: Readonly>; terminalLabelsById: ReadonlyMap; onActivate: (surface: RightPanelSurface) => void; onCloseSurface: (surface: RightPanelSurface) => void; onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; children: ReactNode; } const SURFACE_DISABLED_REASONS = { browser: "Browser previews are only available in the T3 Code desktop app.", files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", } as const; type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { return ( {props.reason} ); } function SurfaceMenuItem(props: { available: boolean; disabledReason?: string; onClick: () => void; children: ReactNode; }) { const item = ( {props.children} ); if (props.available || !props.disabledReason) return item; return ; } function RightPanelEmptyState(props: { onAddBrowser: () => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; }) { const actions = [ { label: "Browser", description: "Open a local app or URL.", icon: Globe2, available: props.browserAvailable, disabledReason: SURFACE_DISABLED_REASONS.browser, onClick: props.onAddBrowser, }, { label: "Terminal", description: "Start a shell in this workspace.", icon: TerminalSquare, available: true, disabledReason: null, onClick: props.onAddTerminal, }, { label: "Files", description: "Browse and read workspace files.", icon: Files, available: props.filesAvailable, disabledReason: SURFACE_DISABLED_REASONS.files, onClick: props.onAddFiles, }, { label: "Diff", description: "Review changes in this thread.", icon: FileDiff, available: props.diffAvailable, disabledReason: SURFACE_DISABLED_REASONS.diff, onClick: props.onAddDiff, }, ] as const; return (

Open a surface

Choose what to show in the right panel.

{actions.map((action) => { const Icon = action.icon; const content = ( <> {action.label} {action.description} ); if (action.available) { return ( ); } const disabledCard = ( ); return ( ); })}
); } function surfaceTitle( surface: RightPanelSurface, sessions: Readonly>, terminalLabelsById: ReadonlyMap, ): string { switch (surface.kind) { case "diff": return "Diff"; case "files": return "Files"; case "file": return surface.relativePath.slice(surface.relativePath.lastIndexOf("/") + 1); case "terminal": return ( terminalLabelsById.get(surface.activeTerminalId) ?? getTerminalLabel(surface.activeTerminalId) ); case "plan": return "Plan"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title; try { return new URL(snapshot.navStatus.url).host || "Browser"; } catch { return "Browser"; } } } } function PreviewFavicon({ url }: { url: string | null }) { const faviconUrl = faviconUrlForOrigin(url, 32); const [failedUrl, setFailedUrl] = useState(null); if (!faviconUrl || failedUrl === faviconUrl) return ; return ( setFailedUrl(faviconUrl)} /> ); } function SurfaceIcon({ surface, sessions, theme, }: { surface: RightPanelSurface; sessions: Readonly>; theme: "light" | "dark"; }) { switch (surface.kind) { case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; return ; } case "diff": return ; case "files": return ; case "file": return ( ); case "terminal": return ; case "plan": return ; } } export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const handleTabContextMenu = useCallback( async (event: ReactMouseEvent, surface: RightPanelSurface) => { event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; const surfaceIndex = props.surfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; const items: ContextMenuItem[] = []; if (surface.kind === "file") { items.push({ id: "copy-path", label: "Copy path" }); } items.push( { id: "close", label: "Close" }, { id: "close-others", label: "Close others", disabled: props.surfaces.length <= 1, }, { id: "close-to-right", label: "Close to the right", disabled: surfaceIndex >= props.surfaces.length - 1, }, { id: "close-all", label: "Close all", disabled: props.surfaces.length === 0, }, ); const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY }); switch (action) { case "copy-path": if (surface.kind === "file") props.onCopyFilePath(surface.relativePath); break; case "close": props.onCloseSurface(surface); break; case "close-others": props.onCloseOtherSurfaces(surface); break; case "close-to-right": props.onCloseSurfacesToRight(surface); break; case "close-all": props.onCloseAllSurfaces(); break; case null: break; } }, [props], ); const handleTabMouseDown = useCallback((event: ReactMouseEvent) => { if (event.button !== 1) return; event.preventDefault(); }, []); const handleTabAuxClick = useCallback( (event: ReactMouseEvent, surface: RightPanelSurface) => { if (event.button !== 1) return; event.preventDefault(); event.stopPropagation(); props.onCloseSurface(surface); }, [props], ); useEffect(() => { const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); }, [props.activeSurfaceId]); return (
{props.surfaces.map((surface) => { const active = surface.id === props.activeSurfaceId; const pending = props.pendingSurfaceIds.has(surface.id); const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); return (
handleTabAuxClick(event, surface)} onContextMenu={(event) => void handleTabContextMenu(event, surface)} className={cn( "group flex h-7 min-w-25 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-sm", active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > props.onActivate(surface)} > {title} } /> {title}
); })} {props.surfaces.length > 0 ? ( Browser Terminal Files Diff ) : null}
{props.layoutControls}
{props.activeSurfaceId === null ? ( ) : ( props.children )}
); }