2187 lines · 74.1 KB · TypeScript
RawDownload
import {  EDITORS,  type EditorId,  type ProjectEntry,  ModelSlug,  type NativeApi,  PROVIDER_SEND_TURN_MAX_INPUT_CHARS,  PROVIDER_SEND_TURN_MAX_ATTACHMENTS,  PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,  type ResolvedKeybindingsConfig,  type ProviderApprovalDecision,  type ProviderSendTurnAttachmentInput,} from "@t3tools/contracts";import {  type ClipboardEvent,  type DragEvent,  type FormEvent,  Fragment,  type KeyboardEvent,  memo,  type RefObject,  useCallback,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,} from "react";import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; import { isElectron } from "../env";import { buildBootstrapInput } from "../historyBootstrap";import {  type ComposerTriggerKind,  detectComposerTrigger,  replaceTextRange,} from "../composer-logic";import {  DEFAULT_MODEL,  DEFAULT_REASONING,  MODEL_OPTIONS,  REASONING_OPTIONS,  ReasoningEffort,  resolveModelSlug,} from "../model-logic";import {  derivePendingApprovals,  derivePhase,  deriveTimelineEntries,  type PendingApproval,  deriveWorkLogEntries,  formatDuration,  formatElapsed,  formatTimestamp,} from "../session-logic";import { isScrollContainerNearBottom } from "../chat-scroll";import { useStore } from "../store";import type { ChatImageAttachment } from "../types";import { getVscodeIconUrlForEntry } from "../vscode-icons";import { useTheme } from "../hooks/useTheme";import BranchToolbar from "./BranchToolbar";import GitActionsControl from "./GitActionsControl";import {  isOpenFavoriteEditorShortcut,  isTerminalCloseShortcut,  isTerminalNewShortcut,  isTerminalSplitShortcut,  isTerminalToggleShortcut,  shortcutLabelForCommand,} from "../keybindings";import ChatMarkdown from "./ChatMarkdown";import ThreadTerminalDrawer from "./ThreadTerminalDrawer";import { useNativeApi } from "../hooks/useNativeApi";import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";import {  BotIcon,  ChevronDownIcon,  CircleAlertIcon,  FileIcon,  FolderIcon,  FolderClosedIcon,  InfoIcon,  LockIcon,  LockOpenIcon,  XIcon,} from "lucide-react";import { Button } from "./ui/button";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";import { Separator } from "./ui/separator";import { Group, GroupSeparator } from "./ui/group";import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";import { CursorIcon, Icon } from "./Icons";import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";import { Badge } from "./ui/badge";import { Command, CommandItem, CommandList } from "./ui/command"; function formatMessageMeta(createdAt: string, duration: string | null): string {  if (!duration) return formatTimestamp(createdAt);  return `${formatTimestamp(createdAt)} • ${duration}`;} const LAST_EDITOR_KEY = "t3code:last-editor";const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;const IMAGE_ONLY_BOOTSTRAP_PROMPT =  "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({  slug,  name,  searchSlug: slug.toLowerCase(),  searchName: name.toLowerCase(),})); function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {  if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";  if (tone === "tool") return "text-muted-foreground/70";  if (tone === "thinking") return "text-muted-foreground/50";  return "text-muted-foreground/40";} function basenameOfPath(pathValue: string): string {  const slashIndex = pathValue.lastIndexOf("/");  if (slashIndex === -1) return pathValue;  return pathValue.slice(slashIndex + 1);} type SessionContinuityState = "resumed" | "new" | "fallback_new"; interface EnsuredSessionInfo {  sessionId: string;  resolvedThreadId: string | null;  continuityState: SessionContinuityState;} interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> {  previewUrl: string;  file: File;} interface ExpandedImagePreview {  src: string;  name: string;} type ComposerCommandItem =  | {      id: string;      type: "path";      path: string;      pathKind: ProjectEntry["kind"];      label: string;      description: string;    }  | {      id: string;      type: "slash-command";      label: string;      description: string;    }  | {      id: string;      type: "model";      model: ModelSlug;      label: string;      description: string;    }; function readFileAsDataUrl(file: File): Promise<string> {  return new Promise((resolve, reject) => {    const reader = new FileReader();    reader.addEventListener("load", () => {      if (typeof reader.result === "string") {        resolve(reader.result);        return;      }      reject(new Error("Could not read image data."));    });    reader.addEventListener("error", () => {      reject(reader.error ?? new Error("Failed to read image."));    });    reader.readAsDataURL(file);  });} const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {  pathValue: string;  kind: "file" | "directory";  theme: "light" | "dark";}) {  const [failed, setFailed] = useState(false);  const iconUrl = useMemo(    () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme),    [props.kind, props.pathValue, props.theme],  );   useEffect(() => {    setFailed(false);  }, [iconUrl]);   if (failed) {    return props.kind === "directory" ? (      <FolderIcon className="size-4 text-muted-foreground/80" />    ) : (      <FileIcon className="size-4 text-muted-foreground/80" />    );  }   return (    <img      src={iconUrl}      alt=""      aria-hidden="true"      className="size-4 shrink-0"      loading="lazy"      onError={() => setFailed(true)}    />  );}); const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {  item: ComposerCommandItem;  index: number;  isHighlighted: boolean;  keyboardHighlight: boolean;  resolvedTheme: "light" | "dark";  onHighlight: (index: number) => void;  onSelect: (item: ComposerCommandItem) => void;}) {  return (    <CommandItem      value={props.item.id}      data-composer-menu-index={props.index}      className={cn(        "cursor-pointer gap-2",        props.keyboardHighlight &&          !props.isHighlighted &&          "data-highlighted:bg-transparent data-highlighted:text-inherit",        props.isHighlighted && "bg-accent text-accent-foreground",      )}      onMouseEnter={() => {        props.onHighlight(props.index);      }}      onMouseDown={(event) => {        event.preventDefault();      }}      onClick={() => {        props.onSelect(props.item);      }}    >      {props.item.type === "path" ? (        <VscodeEntryIcon          pathValue={props.item.path}          kind={props.item.pathKind}          theme={props.resolvedTheme}        />      ) : null}      {props.item.type === "slash-command" ? (        <BotIcon className="size-4 text-muted-foreground/80" />      ) : null}      {props.item.type === "model" ? (        <Badge variant="outline" className="px-1.5 py-0 text-[10px]">          model        </Badge>      ) : null}      <span className="truncate">{props.item.label}</span>      <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span>    </CommandItem>  );}); const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {  items: ComposerCommandItem[];  selectedIndex: number;  keyboardHighlight: boolean;  resolvedTheme: "light" | "dark";  isLoading: boolean;  triggerKind: ComposerTriggerKind | null;  onHighlight: (index: number) => void;  onSelect: (item: ComposerCommandItem) => void;  menuRef: RefObject<HTMLDivElement | null>;}) {  return (    <Command>      <div        ref={props.menuRef}        className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"      >        <CommandList className="max-h-64">          {props.items.map((item, index) => (            <ComposerCommandMenuItem              key={item.id}              item={item}              index={index}              isHighlighted={index === props.selectedIndex}              keyboardHighlight={props.keyboardHighlight}              resolvedTheme={props.resolvedTheme}              onHighlight={props.onHighlight}              onSelect={props.onSelect}            />          ))}        </CommandList>        {props.items.length === 0 && (          <p className="px-3 py-2 text-muted-foreground/70 text-xs">            {props.isLoading              ? "Searching workspace files..."              : props.triggerKind === "path"                ? "No matching files or folders."                : "No matching command."}          </p>        )}      </div>    </Command>  );}); export default function ChatView() {  const { state, dispatch } = useStore();  const api = useNativeApi();  const { resolvedTheme } = useTheme();  const queryClient = useQueryClient();  const createWorktreeMutation = useMutation(    gitCreateWorktreeMutationOptions({ api, queryClient }),  );  const [prompt, setPrompt] = useState("");  const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);  const [isDragOverComposer, setIsDragOverComposer] = useState(false);  const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);  const [isSending, setIsSending] = useState(false);  const [isConnecting, setIsConnecting] = useState(false);  const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);  const [envMode, setEnvMode] = useState<"local" | "worktree">("local");  const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);  const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]);  const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});  const [nowTick, setNowTick] = useState(() => Date.now());  const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);  const [composerCursor, setComposerCursor] = useState(0);  const [debouncedPathQuery, setDebouncedPathQuery] = useState("");  const [composerMenuIndex, setComposerMenuIndex] = useState(0);  const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false);  const messagesScrollRef = useRef<HTMLDivElement>(null);  const messagesEndRef = useRef<HTMLDivElement>(null);  const shouldAutoScrollRef = useRef(true);  const textareaRef = useRef<HTMLTextAreaElement>(null);  const composerMenuRef = useRef<HTMLDivElement>(null);  const previousComposerMenuIndexRef = useRef(0);  const composerImagesRef = useRef<ComposerImageAttachment[]>([]);  const dragDepthRef = useRef(0);  const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});   const activeThread = state.threads.find((t) => t.id === state.activeThreadId);  const activeThreadId = activeThread?.id ?? null;  const activeSessionId = activeThread?.session?.sessionId;  const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);  const selectedModel = resolveModelSlug(    activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,  );  const phase = derivePhase(activeThread?.session ?? null);  const isWorking = phase === "running" || isSending || isConnecting;  const nowIso = new Date(nowTick).toISOString();  const workLogEntries = useMemo(    () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),    [activeThread?.events],  );  const latestTurnWorkEntries = useMemo(    () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId),    [activeThread?.events, activeThread?.latestTurnId],  );  const pendingApprovals = useMemo(    () => derivePendingApprovals(activeThread?.events ?? []),    [activeThread?.events],  );  const assistantCompletionByItemId = useMemo(() => {    const map = new Map<string, string>();    const ordered = [...(activeThread?.events ?? [])].toReversed();    for (const event of ordered) {      if (event.method !== "item/completed") continue;      if (!event.itemId) continue;      map.set(event.itemId, event.createdAt);    }    return map;  }, [activeThread?.events]);  const timelineEntries = useMemo(    () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),    [activeThread?.messages, workLogEntries],  );  const completionSummary = useMemo(() => {    if (!activeThread?.latestTurnStartedAt) return null;    if (!activeThread.latestTurnCompletedAt) return null;    if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) {      return null;    }     if (      typeof activeThread.latestTurnDurationMs === "number" &&      Number.isFinite(activeThread.latestTurnDurationMs) &&      activeThread.latestTurnDurationMs >= 0    ) {      return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`;    }     const elapsed = formatElapsed(      activeThread.latestTurnStartedAt,      activeThread.latestTurnCompletedAt,    );    return elapsed ? `Worked for ${elapsed}` : null;  }, [    activeThread?.latestTurnStartedAt,    activeThread?.latestTurnCompletedAt,    activeThread?.latestTurnDurationMs,    latestTurnWorkEntries,  ]);  const completionDividerBeforeEntryId = useMemo(() => {    if (!activeThread?.latestTurnStartedAt) return null;    if (!activeThread.latestTurnCompletedAt) return null;    if (!completionSummary) return null;     const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt);    const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt);    if (Number.isNaN(turnStartedAt)) return null;    if (Number.isNaN(turnCompletedAt)) return null;     let inRangeMatch: string | null = null;    let fallbackMatch: string | null = null;    for (const timelineEntry of timelineEntries) {      if (timelineEntry.kind !== "message") continue;      if (timelineEntry.message.role !== "assistant") continue;      const messageAt = Date.parse(timelineEntry.message.createdAt);      if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue;      fallbackMatch = timelineEntry.id;      if (messageAt <= turnCompletedAt) {        inRangeMatch = timelineEntry.id;      }    }    return inRangeMatch ?? fallbackMatch;  }, [    activeThread?.latestTurnCompletedAt,    activeThread?.latestTurnStartedAt,    completionSummary,    timelineEntries,  ]);  const runtimeSessionConfig =    state.runtimeMode === "full-access"      ? ({          approvalPolicy: "never",          sandboxMode: "danger-full-access",        } as const)      : ({          approvalPolicy: "on-request",          sandboxMode: "workspace-write",        } as const);  const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;  const composerTrigger = useMemo(    () => detectComposerTrigger(prompt, composerCursor),    [prompt, composerCursor],  );  const composerTriggerKind = composerTrigger?.kind ?? null;  const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";  const isPathTrigger = composerTriggerKind === "path";  const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));  const keybindingsQuery = useQuery({    ...serverConfigQueryOptions(api),    select: (config) => config.keybindings,  });  useEffect(() => {    if (!isPathTrigger) {      setDebouncedPathQuery("");      return;    }    if (pathTriggerQuery.length === 0) {      setDebouncedPathQuery("");      return;    }    const timer = window.setTimeout(() => {      setDebouncedPathQuery(pathTriggerQuery);    }, COMPOSER_PATH_QUERY_DEBOUNCE_MS);    return () => {      window.clearTimeout(timer);    };  }, [isPathTrigger, pathTriggerQuery]);  const workspaceEntriesQuery = useQuery({    queryKey: [      "composer-workspace-entries",      gitCwd,      debouncedPathQuery,    ],    enabled: Boolean(api && gitCwd && isPathTrigger),    staleTime: 15_000,    placeholderData: (previous) => previous,    queryFn: async () => {      if (!api || !gitCwd || !isPathTrigger) {        return { entries: [], truncated: false };      }      return api.projects.searchEntries({        cwd: gitCwd,

Lines 1–500 of 2187.