1416 lines · 52.0 KB · TypeScript
RawDownload
import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities";import {  threadPullRequestKeysEqual,  visibleThreadPullRequests,} from "@t3tools/shared/threadPullRequests";import type {  ContextMenuItem,  EnvironmentId,  PreviewSessionSnapshot,  ProjectId,  PullRequestState,} from "@t3tools/contracts";import { getTerminalLabel } from "@t3tools/shared/terminalLabels";import {  Bot,  Smartphone,  ChevronDown,  ChevronLeft,  ChevronRight,  FileDiff,  Files,  GitPullRequest,  GitPullRequestArrow,  Globe2,  Plus,  TerminalSquare,  Volume2,  VolumeOff,} from "lucide-react";import {  type KeyboardEvent as ReactKeyboardEvent,  type MouseEvent as ReactMouseEvent,  type ReactElement,  type ReactNode,  useCallback,  useEffect,  useRef,  useState,} from "react"; import { isElectron } from "~/env";import type { DesktopPreviewOverlay } from "~/previewStateStore";import type { RightPanelSurface } from "~/rightPanelStore";import { cn } from "~/lib/utils";import { readLocalApi } from "~/localApi";import { Button } from "~/components/ui/button";import { AndroidIcon, AppleIcon } from "~/components/Icons";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";import { Kbd } from "~/components/ui/kbd";import {  Menu,  MenuItem,  MenuPopup,  MenuShortcut,  MenuSub,  MenuSubPopup,  MenuSubTrigger,  MenuTrigger,} from "~/components/ui/menu";import { useBrowserDefaults } from "~/browser/browserDefaults";import { ScrollArea } from "~/components/ui/scroll-area";import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";import { faviconUrlForOrigin } from "~/lib/favicon";import { useTheme } from "~/hooks/useTheme";import { pullRequestEnvironment } from "~/state/pullRequests";import { useEnvironmentQuery } from "~/state/query";import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";import { FaviconImage } from "./preview/PreviewFaviconIcon";import { previewBridge } from "./preview/previewBridge";import { PierreEntryIcon } from "./chat/PierreEntryIcon";import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; interface RightPanelTabsProps {  mode: PreviewPanelMode;  maximized?: boolean;  open?: boolean;  /** Forwarded to PreviewPanelShell so this surface persists its own width. */  widthStorageKey?: string;  /** Forwarded to PreviewPanelShell as the initial width before a user resize. */  defaultWidth?: number;  layoutControls?: ReactNode;  surfaces: readonly RightPanelSurface[];  /** Fallback environment for surfaces that do not carry their own. */  environmentId: EnvironmentId | null;  activeSurfaceId: string | null;  pendingSurfaceIds: ReadonlySet<string>;  previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;  desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>;  /**   * Maps a server session tab id to the desktop runtime tab id the Electron   * preview manager is keyed by. Session ids are only unique within one server   * process, so desktop operations must not be addressed with them.   */  previewRuntimeTabId?: ((tabId: string) => string) | undefined;  terminalLabelsById: ReadonlyMap<string, string>;  onActivate: (surface: RightPanelSurface) => void;  onRenameDevice?: (surfaceId: string, title: string) => void;  onCloseSurface: (surface: RightPanelSurface) => void;  onCloseOtherSurfaces: (surface: RightPanelSurface) => void;  onCloseSurfacesToRight: (surface: RightPanelSurface) => void;  onCloseAllSurfaces: () => void;  onCopyFilePath: (relativePath: string) => void;  onAddBrowser: () => void;  /**   * Separate from `onAddBrowser` on purpose: that one is passed directly as a   * DOM click handler, and a `(profileId?: string)` signature would silently   * accept the MouseEvent as a profile id.   */  onAddBrowserInProfile: (profileId: string) => void;  onAddTerminal: () => void;  onAddDiff: () => void;  onAddFiles: () => void;  onAddPullRequest: () => void;  onAddPullRequests: () => void;  onAddAgents: () => void;  onAddDevice: () => void;  browserAvailable: boolean;  terminalAvailable: boolean;  diffAvailable: boolean;  filesAvailable: boolean;  pullRequestAvailable: boolean;  pullRequestsAvailable: boolean;  agentsAvailable: boolean;  deviceAvailable: boolean;  pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>;  /** Running + waiting subagents; badges the Agents card in the empty state. */  liveAgentCount: number;  children: ReactNode;} export interface PullRequestTabStatus {  projectId: string;  repository: string;  number: number;  state: PullRequestState;  isDraft: boolean;} export type PullRequestTabStatusSeed = Pick<PullRequestTabStatus, "state" | "isDraft">; export function shouldOpenDefaultBrowserProfileFromMenuClick(  pointerType: string | undefined,): boolean {  return pointerType !== "touch";} const SURFACE_DISABLED_REASONS = {  browser: "Browser previews are only available in the T3 Code desktop app.",  terminal: "Terminal surfaces are only available from a project thread.",  files: "Files are only available when a project is open.",  diff: "Diff is only available for server threads in Git repositories.",  pullRequest: "This thread's branch has no pull request yet.",  pullRequests: "Linked pull requests are only available for server threads.",  agents: "Agents are only available from a thread.",  device: "Devices are only available from a thread.",} as const; /** Overlays that must win over the launcher's letter shortcuts. */const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [  '[data-slot="dialog-popup"]',  '[data-slot="alert-dialog-popup"]',  '[data-slot="command-dialog-popup"]',  '[data-slot="menu-popup"]',  '[data-slot="select-popup"]',  '[data-slot="popover-popup"]',  '[data-slot="combobox-popup"]',  '[data-slot="autocomplete-popup"]',].join(","); /** One-line unavailability hints for the empty-state rows. */const SURFACE_UNAVAILABLE_HINTS = {  browser: "Only available in the desktop app.",  terminal: "Available when a project is open.",  files: "Available when a project is open.",  diff: "Available for Git repositories.",  pullRequest: "No pull request on this branch yet.",  pullRequests: "Available for server threads.",  agents: "Available from a thread.",  device: "Available from a thread.",} as const; type TabContextMenuAction =  | "rename"  | "copy-path"  | "toggle-mute"  | "close"  | "close-others"  | "close-to-right"  | "close-all"; const TAB_SCROLL_EDGE_TOLERANCE = 1; function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null {  return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null;} /** * Desktop preview tab backing a surface, or null for non-preview surfaces, the * "new browser tab" placeholder, and the web build where no desktop tab exists. */function previewTabIdOf(  surface: RightPanelSurface,  sessions: Readonly<Record<string, PreviewSessionSnapshot>>,): string | null {  if (surface.kind !== "preview" || !surface.resourceId) return null;  return sessions[surface.resourceId]?.tabId ?? null;} /** * Label and enabled state for a preview tab's mute menu entry. * Stays disabled until desktop overlay state arrives: a server session id can * resolve while the preview manager's createTab is still in flight, and muting * then fails with a PreviewTabNotFoundError nothing surfaces to the user. */export function tabMuteMenuItem(input: {  overlay: DesktopPreviewOverlay | null;  canResolveRuntimeTabId: boolean;}): { label: string; disabled: boolean } {  const muted = input.overlay?.audioMuted ?? false;  return {    label: muted ? "Unmute tab" : "Mute tab",    disabled: input.overlay === null || !input.canResolveRuntimeTabId,  };} type TabAudioState = "none" | "audible" | "muted"; /** * A muted tab that is not making sound shows nothing: mute is armed silently, * and the indicator only appears once there is audio to speak of. */function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState {  if (!overlay?.audible) return "none";  return overlay.audioMuted ? "muted" : "audible";} type SurfaceShortcutEvent = Pick<  KeyboardEvent,  "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey">; export function surfaceShortcutActionForKey<  const Action extends { available: boolean; shortcut: string },>(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null {  if (event.defaultPrevented || event.isComposing) return null;  if (event.metaKey || event.ctrlKey || event.altKey) return null;  return (    actions.find(      (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(),    ) ?? null  );} /** * A focused editable is a typing context whether or not it has text yet: an * empty chat composer at rest is still where the user's next keystrokes are * meant to land, and claiming launcher letters from it would redirect prompts * into whatever surface opens. The `:not` clause lets `closest` see past * non-editable islands (`contenteditable="false"`) to an editable host around * them, matching ComposerPendingUserInputPanel's typing guard. */export function surfaceShortcutTargetsTypingContext(  target: { closest(selectors: string): unknown } | null,): boolean {  return (    target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !=    null  );} function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {  return (    <Tooltip>      <TooltipTrigger render={props.trigger} />      <TooltipPopup side="top">{props.reason}</TooltipPopup>    </Tooltip>  );} function SurfaceMenuItem(props: {  available: boolean;  disabledReason?: string;  shortcut: string;  onClick: () => void;  children: ReactNode;}) {  const item = (    <MenuItem      className={!props.available ? "data-disabled:pointer-events-auto" : undefined}      onClick={props.onClick}      disabled={!props.available}      aria-keyshortcuts={props.shortcut}    >      {props.children}      <MenuShortcut>{props.shortcut}</MenuShortcut>    </MenuItem>  );  if (props.available || !props.disabledReason) return item;  return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />;} /** * List launcher shown when the right panel has no surfaces. Keyboard-first * without palette chrome: a surface's letter opens it directly from anywhere * outside a typing context, and arrows plus Enter work while the launcher is * focused. The highlight only appears on hover or arrow use. Unavailable * surfaces stay visible with a one-line reason. */function RightPanelEmptyState(props: {  onAddBrowser: () => void;  onAddBrowserInProfile: (profileId: string) => void;  browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>;  onAddTerminal: () => void;  onAddDiff: () => void;  onAddFiles: () => void;  onAddPullRequest: () => void;  onAddPullRequests: () => void;  onAddAgents: () => void;  onAddDevice: () => void;  browserAvailable: boolean;  terminalAvailable: boolean;  diffAvailable: boolean;  filesAvailable: boolean;  pullRequestAvailable: boolean;  pullRequestsAvailable: boolean;  agentsAvailable: boolean;  deviceAvailable: boolean;  liveAgentCount: number;}) {  // -1 means no highlight: it only appears on hover or arrow use.  const [highlight, setHighlight] = useState(-1);   const actions = [    {      label: "Browser",      icon: Globe2,      shortcut: "B",      available: props.browserAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.browser,      onClick: props.onAddBrowser,      badgeCount: 0,    },    {      label: "Terminal",      icon: TerminalSquare,      shortcut: "T",      available: props.terminalAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal,      onClick: props.onAddTerminal,      badgeCount: 0,    },    {      label: "Files",      icon: Files,      shortcut: "F",      available: props.filesAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.files,      onClick: props.onAddFiles,      badgeCount: 0,    },    {      label: "Diff",      icon: FileDiff,      shortcut: "D",      available: props.diffAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.diff,      onClick: props.onAddDiff,      badgeCount: 0,    },    {      label: "Pull request",      icon: GitPullRequest,      shortcut: "P",      available: props.pullRequestAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest,      onClick: props.onAddPullRequest,      badgeCount: 0,    },    {      label: "Linked pull requests",      icon: GitPullRequestArrow,      shortcut: "L",      available: props.pullRequestsAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests,      onClick: props.onAddPullRequests,      badgeCount: 0,    },    {      label: "Agents",      icon: Bot,      shortcut: "A",      available: props.agentsAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.agents,      onClick: props.onAddAgents,      badgeCount: props.liveAgentCount,    },    {      label: "Device",      description: "Watch an iOS Simulator or Android Emulator.",      icon: Smartphone,      shortcut: "M",      available: props.deviceAvailable,      disabledReason: SURFACE_UNAVAILABLE_HINTS.device,      onClick: props.onAddDevice,      badgeCount: 0,    },  ] as const;   type SurfaceAction = (typeof actions)[number];   const availableActions = actions.filter((action) => action.available);  const highlightIndex =    availableActions.length === 0 ? -1 : Math.min(highlight, availableActions.length - 1);   // Letter shortcuts work while the launcher is visible, not only while it  // is focused; focus moves around too easily (stray clicks) to carry them.  // Capture phase so app-level key handlers cannot swallow the event first;  // typing contexts and already-handled events are left alone.  const shortcutActionsRef = useRef(availableActions);  useEffect(() => {    shortcutActionsRef.current = availableActions;  });  useEffect(() => {    const handler = (event: KeyboardEvent) => {      const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event);      if (!action) return;      if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;      const target = event.target;      if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return;      event.preventDefault();      event.stopPropagation();      action.onClick();    };    window.addEventListener("keydown", handler, true);    return () => window.removeEventListener("keydown", handler, true);  }, []);   const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {    if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;    if (availableActions.length === 0) return;    if (event.key === "ArrowDown" || event.key === "ArrowRight") {      event.preventDefault();      setHighlight((highlightIndex + 1) % availableActions.length);      return;    }    if (event.key === "ArrowUp" || event.key === "ArrowLeft") {      event.preventDefault();      setHighlight(        highlightIndex === -1          ? availableActions.length - 1          : (highlightIndex - 1 + availableActions.length) % availableActions.length,      );      return;    }    if (event.key === "Enter") {      // Only activate the highlight when the launcher itself has focus.      if (event.target !== event.currentTarget) return;      const action = availableActions[highlightIndex];      if (!action) return;      event.preventDefault();      action.onClick();    }  };   // Stable identity so React only runs this callback ref on mount/unmount;  // an inline arrow would re-attach and re-focus on every render.  const focusOnMount = useCallback((node: HTMLDivElement | null) => {    node?.focus();  }, []);   const isHighlighted = (action: SurfaceAction) =>    highlightIndex !== -1 && availableActions[highlightIndex] === action;   const actionIcon = (action: SurfaceAction, iconClassName = "size-4") => {    const Icon = action.icon;    return (      <span className="relative inline-flex shrink-0">        <Icon className={iconClassName} />        {action.badgeCount > 0 ? (          <span            aria-hidden            className="absolute -top-1.5 -right-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-info px-1 text-[9px] font-semibold tabular-nums text-white"          >            {action.badgeCount}          </span>        ) : null}      </span>    );  };   return (    <div      ref={focusOnMount}      tabIndex={0}      onKeyDown={handleKeyDown}      aria-label="Open a surface"

Lines 1–500 of 1416.