637 lines · 24.6 KB · TypeScript
RawDownload
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";import type { EnvironmentId, ThreadId } from "@t3tools/contracts";import {  ChevronDownIcon,  FolderGit2Icon,  FolderGitIcon,  FolderIcon,  HistoryIcon,  ScaleIcon,} from "lucide-react";import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore";import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";import { useProject, useThreadShell, useThreadShellsForProjectRefs } from "../state/entities";import {  type EnvMode,  type EnvironmentOption,  resolveContextStripLabelsCompact,  resolveCurrentWorkspaceLabel,  resolveEnvModeLabel,  resolveEffectiveEnvMode,  resolveLockedWorkspaceLabel,  resolvePreviousWorktreeLabel,  resolvePreviousWorktreeSeed,  shouldShowEnvironmentIndicator,} from "./BranchToolbar.logic";import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";import { Button } from "./ui/button";import {  Menu,  MenuGroup,  MenuGroupLabel,  MenuPopup,  MenuRadioGroup,  MenuRadioItem,  MenuSeparator,  MenuTrigger,} from "./ui/menu";import { Separator } from "./ui/separator";import { ComposerSurface } from "./chat/ComposerSurface";import { composerFloatingLayerProps } from "./chat/composerEventScope";import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";import { cn } from "~/lib/utils"; interface BranchToolbarProps {  environmentId: EnvironmentId;  threadId: ThreadId;  showGitControls: boolean;  draftId?: DraftId;  onEnvModeChange: (mode: EnvMode) => void;  effectiveEnvModeOverride?: EnvMode;  activeThreadBranchOverride?: string | null;  onActiveThreadBranchOverrideChange?: (branch: string | null) => void;  startFromOrigin: boolean;  onStartFromOriginChange: (startFromOrigin: boolean) => void;  autoEnvironmentLabel?: string | undefined;  onAutoEnvironment?: (() => void) | undefined;  envLocked: boolean;  onCheckoutPullRequestRequest?: (reference: string) => void;  onComposerFocusRequest?: () => void;  availableEnvironments?: readonly EnvironmentOption[];  onEnvironmentChange?: (environmentId: EnvironmentId) => void;  composerControlsHostRef?: (element: HTMLDivElement | null) => void;  contextStripVisible?: boolean;} interface MobileRunContextSelectorProps {  autoEnvironmentLabel?: string | undefined;  onAutoEnvironment?: (() => void) | undefined;  envLocked: boolean;  envModeLocked: boolean;  environmentId: EnvironmentId;  availableEnvironments: readonly EnvironmentOption[] | undefined;  showEnvironmentPicker: boolean;  showEnvironmentIndicator: boolean;  onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined;  effectiveEnvMode: EnvMode;  activeWorktreePath: string | null;  onEnvModeChange: (mode: EnvMode) => void;  previousWorktreeLabel: string | null;  onUsePreviousWorktree: () => void;} const MobileRunContextSelector = memo(function MobileRunContextSelector({  autoEnvironmentLabel,  onAutoEnvironment,  envLocked,  envModeLocked,  environmentId,  availableEnvironments,  showEnvironmentPicker,  showEnvironmentIndicator,  onEnvironmentChange,  effectiveEnvMode,  activeWorktreePath,  onEnvModeChange,  previousWorktreeLabel,  onUsePreviousWorktree,}: MobileRunContextSelectorProps) {  const activeEnvironment = useMemo(    () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,    [availableEnvironments, environmentId],  );  const WorkspaceIcon =    effectiveEnvMode === "worktree"      ? FolderGit2Icon      : activeWorktreePath        ? FolderGitIcon        : FolderIcon;  const workspaceLabel = envModeLocked    ? resolveLockedWorkspaceLabel(activeWorktreePath)    : effectiveEnvMode === "worktree"      ? resolveEnvModeLabel("worktree")      : resolveCurrentWorkspaceLabel(activeWorktreePath);  const isLocked = envLocked || envModeLocked;  const icon = showEnvironmentIndicator ? (    // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px    // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px.    <span className="inline-flex shrink-0 items-center gap-0.5">      {autoEnvironmentLabel ? (        <ScaleIcon className="size-3 shrink-0 mx-0!" aria-hidden="true" />      ) : (        <EnvironmentMachineIcon          kind={activeEnvironment?.machine ?? "server"}          className="size-3 shrink-0 mx-0!"        />      )}      <WorkspaceIcon className="size-3 shrink-0 mx-0!" />    </span>  ) : (    <WorkspaceIcon className="size-3 shrink-0" />  );  const triggerContent = (    <>      {icon}      <span        data-composer-label        className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"      >        <span          data-composer-label-motion          className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"        >          {autoEnvironmentLabel ??            (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)}        </span>      </span>    </>  );   if (isLocked) {    return (      <span        className="inline-flex h-7 min-w-0 max-w-[48%] flex-initial items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6"        data-composer-context-control      >        {triggerContent}      </span>    );  }   return (    <Menu>      <MenuTrigger        render={<Button variant="ghost" size="xs" />}        className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80"        data-composer-context-control      >        {triggerContent}        <ChevronDownIcon className="size-3 shrink-0 opacity-50" />      </MenuTrigger>      <MenuPopup align="start" side="top" className="w-64" {...composerFloatingLayerProps}>        {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? (          <>            <MenuGroup>              <MenuGroupLabel>Run on</MenuGroupLabel>              <MenuRadioGroup                value={autoEnvironmentLabel ? "auto" : environmentId}                onValueChange={(value) =>                  value === "auto"                    ? onAutoEnvironment?.()                    : onEnvironmentChange(value as EnvironmentId)                }              >                {onAutoEnvironment && (                  <MenuRadioItem                    value="auto"                    disabled={envLocked}                    onClick={() => {                      if (autoEnvironmentLabel) onAutoEnvironment?.();                    }}                  >                    <span className="flex min-w-0 items-center gap-1.5">                      <ScaleIcon className="size-3" aria-hidden="true" />                      <span className="min-w-0 truncate">                        {autoEnvironmentLabel ?? "Auto balance"}                      </span>                    </span>                  </MenuRadioItem>                )}                {availableEnvironments.map((env) => (                  <MenuRadioItem                    key={env.environmentId}                    disabled={envLocked}                    value={env.environmentId}                  >                    <span className="flex min-w-0 items-center gap-1.5">                      <EnvironmentMachineIcon kind={env.machine} className="size-3" />                      <span className="min-w-0 truncate">{env.label}</span>                    </span>                  </MenuRadioItem>                ))}              </MenuRadioGroup>            </MenuGroup>            <MenuSeparator />          </>        ) : null}        <MenuGroup>          <MenuGroupLabel>Workspace</MenuGroupLabel>          <MenuRadioGroup            value={effectiveEnvMode}            onValueChange={(value) => {              if (value === "previous-worktree") {                onUsePreviousWorktree();                return;              }              onEnvModeChange(value as EnvMode);            }}          >            <MenuRadioItem disabled={envModeLocked} value="local">              <span className="flex min-w-0 items-center gap-1.5">                {activeWorktreePath ? (                  <FolderGitIcon className="size-3" />                ) : (                  <FolderIcon className="size-3" />                )}                <span className="min-w-0 truncate">                  {resolveCurrentWorkspaceLabel(activeWorktreePath)}                </span>              </span>            </MenuRadioItem>            <MenuRadioItem disabled={envModeLocked} value="worktree">              <span className="flex min-w-0 items-center gap-1.5">                <FolderGit2Icon className="size-3" />                <span className="min-w-0 truncate">{resolveEnvModeLabel("worktree")}</span>              </span>            </MenuRadioItem>            {previousWorktreeLabel ? (              <MenuRadioItem disabled={envModeLocked} value="previous-worktree">                <span className="flex min-w-0 items-center gap-1.5">                  <HistoryIcon className="size-3" />                  <span className="min-w-0 truncate">{previousWorktreeLabel}</span>                </span>              </MenuRadioItem>            ) : null}          </MenuRadioGroup>        </MenuGroup>      </MenuPopup>    </Menu>  );}); /** * Collapse the strip's labels to icons only when the text no longer fits. * * Hidden labels stay measurable because their inner text keeps its natural * width while the outer layout box collapses. This lets every pass recompute * the expanded width without remembered values that could go stale or latch * the strip compact. A small hysteresis keeps the boundary from flapping. */const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]"; function useLabelsOverflow(element: HTMLDivElement | null): boolean {  const [overflows, setOverflows] = useState(false);  const pendingLabelRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);  const labelAnimationsRef = useRef(new Map<HTMLElement, Animation>());  // A render-synced mirror instead of useEffectEvent: the compiler memoizes  // the event callback, which left observers reading the first render's null  // element forever.  const stateRef = useRef({ element, overflows });  stateRef.current = { element, overflows };   const measure = useCallback(() => {    const { element: current, overflows: compact } = stateRef.current;    if (!current) return;    const available = current.clientWidth;    if (available === 0) return;    // flex-1 stretches the groups to fill the strip, so their own boxes always    // measure "full". Sum the laid-out content instead, skipping hidden form    // artifacts and other out-of-flow nodes.    const contentWidth = (parent: Element): number => {      const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0;      let width = 0;      let counted = 0;      for (const child of parent.children) {        if (!(child instanceof HTMLElement)) continue;        if (child.offsetWidth === 0) continue;        const style = getComputedStyle(child);        const position = style.position;        if (position === "absolute" || position === "fixed") continue;        width +=          child.offsetWidth +          (Number.parseFloat(style.marginInlineStart) || 0) +          (Number.parseFloat(style.marginInlineEnd) || 0);        counted += 1;      }      return width + gap * Math.max(0, counted - 1);    };    const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0;    let needed = 0;    let groups = 0;    for (const child of current.children) {      if (!(child instanceof HTMLElement)) continue;      // The host itself flexes into all remaining room. Reserve the natural      // width of the controls inside it, blocks in overflow included, so Git      // labels compact before squeezing out the model picker. Reserving only      // the visible controls would let the labels expand into room the      // composer just freed, shrink the host, and hide the controls again.      const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]')        ? child.querySelector<HTMLElement>('[data-chat-composer-resting-controls="true"]')        : null;      const hostedMeasurement = hostedControls        ? measureRestingComposerControls(hostedControls)        : null;      const width = hostedMeasurement        ? resolveRestingComposerControlsNaturalWidth(hostedMeasurement)        : contentWidth(hostedControls ?? child);      if (width <= 1) continue;      groups += 1;      needed += width;    }    needed += stripGap * Math.max(0, groups - 1);    for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) {      // The clipping can happen below the marker (SelectValue truncates      // internally), where the outer span's scrollWidth matches its clipped      // box. The text's real width is the largest scrollWidth in the subtree.      let textWidth = label.scrollWidth;      for (const inner of label.querySelectorAll<HTMLElement>("*")) {        textWidth = Math.max(textWidth, inner.scrollWidth);      }      // Subtract the visible width even during an animation. The content      // sum already includes it; only the hidden text needs reserving.      needed += Math.max(0, textWidth - label.getBoundingClientRect().width);    }    const nextOverflows = resolveContextStripLabelsCompact({      compact,      neededWidth: needed,      availableWidth: available,    });    if (nextOverflows !== compact) {      pendingLabelRectsRef.current = new Map(        Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_LABEL_SELECTOR)).map(          (label) => [label, label.getBoundingClientRect()],        ),      );    }    setOverflows(nextOverflows);  }, []);   useLayoutEffect(() => {    const previousRects = pendingLabelRectsRef.current;    if (!previousRects) return;    pendingLabelRectsRef.current = null;     for (const animation of labelAnimationsRef.current.values()) {      animation.cancel();    }    labelAnimationsRef.current.clear();     if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;     for (const [label, previousRect] of previousRects) {      if (!label.isConnected) continue;      const nextWidth = label.getBoundingClientRect().width;      if (Math.abs(previousRect.width - nextWidth) < 0.5) continue;       // Animate the space occupied by each label so flex layout keeps the      // trailing controls anchored. Translating the whole group after its      // width snaps sends expanded text beyond the strip's right edge.      const animation = label.animate(        [          { width: `${previousRect.width}px`, maxWidth: `${previousRect.width}px` },          { width: `${nextWidth}px`, maxWidth: `${nextWidth}px` },        ],        {          duration: COMPOSER_CONTEXT_MOTION_DURATION_MS,          easing: COMPOSER_CONTEXT_MOTION_EASING,          fill: "backwards",        },      );      labelAnimationsRef.current.set(label, animation);      animation.addEventListener(        "finish",        () => {          if (labelAnimationsRef.current.get(label) === animation) {            labelAnimationsRef.current.delete(label);          }        },        { once: true },      );    }  }, [overflows]);   useEffect(    () => () => {      for (const animation of labelAnimationsRef.current.values()) {        animation.cancel();      }    },    [],  );   // Label widths can change without the strip box moving (font family or  // size preferences), so re-measure on every render as well as on resize  // and font loads.  useLayoutEffect(() => {    measure();  });   useEffect(() => {    if (!element) return;    const observer = new ResizeObserver(measure);    observer.observe(element);    document.fonts.addEventListener("loadingdone", measure);    return () => {      observer.disconnect();      document.fonts.removeEventListener("loadingdone", measure);    };  }, [element, measure]);   return overflows;} export const BranchToolbar = memo(function BranchToolbar({  environmentId,  threadId,  showGitControls,  draftId,  onEnvModeChange,  effectiveEnvModeOverride,  activeThreadBranchOverride,  onActiveThreadBranchOverrideChange,  startFromOrigin,  onStartFromOriginChange,  autoEnvironmentLabel,  onAutoEnvironment,  envLocked,  onCheckoutPullRequestRequest,  onComposerFocusRequest,  availableEnvironments,  onEnvironmentChange,  composerControlsHostRef,  contextStripVisible = true,}: BranchToolbarProps) {  const threadRef = useMemo(    () => scopeThreadRef(environmentId, threadId),    [environmentId, threadId],  );  const draftThread = useComposerDraftStore((store) =>    draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),  );  const serverThread = useThreadShell(threadRef);  const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);  const activeProjectRef = serverThread    ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)    : draftThread      ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)      : null;  const activeProject = useProject(activeProjectRef);  const hasActiveThread = serverThread !== null || draftThread !== null;  const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;  const effectiveEnvMode =    effectiveEnvModeOverride ??    resolveEffectiveEnvMode({      activeWorktreePath,      hasServerThread: serverThread !== null,      draftThreadEnvMode: draftThread?.envMode,    });  const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null);   // "Previous worktree" hops a draft into the most recently active worktree  // of this project — the "keep going where I just was" follow-up flow. Only  // drafts can hop; started server threads have their workspace pinned.  const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked;  const projectRefsForWorktreeLookup = useMemo(    () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []),    [canUsePreviousWorktree, activeProjectRef],  );  const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup);  const previousWorktreeSeed = useMemo(    () =>      canUsePreviousWorktree        ? resolvePreviousWorktreeSeed({            threads: projectThreads,

Lines 1–500 of 637.