Julius Marmingetidy up0a5aaf38 months agoHistory
1606 lines · 57.8 KB · TypeScript
RawDownload
import {  EDITORS,  type EditorId,  ModelSlug,  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,  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 {  DEFAULT_MODEL,  DEFAULT_REASONING,  MODEL_OPTIONS,  REASONING_OPTIONS,  ReasoningEffort,  resolveModelSlug,} from "../model-logic";import {  derivePendingApprovals,  derivePhase,  deriveTimelineEntries,  deriveWorkLogEntries,  formatDuration,  formatElapsed,  formatTimestamp,} from "../session-logic";import { isScrollContainerNearBottom } from "../chat-scroll";import { useStore } from "../store";import type { ChatImageAttachment } from "../types";import BranchToolbar from "./BranchToolbar";import GitActionsControl from "./GitActionsControl";import {  isOpenFavoriteEditorShortcut,  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 {  ChevronDownIcon,  CircleAlertIcon,  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"; 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 = []; 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";} 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;} 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);  });} export default function ChatView() {  const { state, dispatch } = useStore();  const api = useNativeApi();  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 messagesScrollRef = useRef<HTMLDivElement>(null);  const messagesEndRef = useRef<HTMLDivElement>(null);  const shouldAutoScrollRef = useRef(true);  const textareaRef = useRef<HTMLTextAreaElement>(null);  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 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 branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));  const keybindingsQuery = useQuery({    ...serverConfigQueryOptions(api),    select: (config) => config.keybindings,  });  const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;  // Default true while loading to avoid toolbar flicker.  const isGitRepo = branchesQuery.data?.isRepo ?? true;  const splitTerminalShortcutLabel = useMemo(    () => shortcutLabelForCommand(keybindings, "terminal.split"),    [keybindings],  );  const newTerminalShortcutLabel = useMemo(    () => shortcutLabelForCommand(keybindings, "terminal.new"),    [keybindings],  );   const envLocked = Boolean(    activeThread &&    (activeThread.messages.length > 0 ||      (activeThread.session !== null && activeThread.session.status !== "closed")),  );   const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {    for (const image of images) {      if (!image.previewUrl) continue;      URL.revokeObjectURL(image.previewUrl);    }  }, []);  const focusComposer = useCallback(() => {    const textarea = textareaRef.current;    if (!textarea) return;    textarea.focus();    const cursor = textarea.value.length;    textarea.setSelectionRange(cursor, cursor);  }, []);  const toggleTerminalVisibility = useCallback(() => {    if (!activeThreadId) return;    dispatch({      type: "SET_THREAD_TERMINAL_OPEN",      threadId: activeThreadId,      open: !activeThread?.terminalOpen,    });  }, [activeThread?.terminalOpen, activeThreadId, dispatch]);  const splitTerminal = useCallback(() => {    if (!activeThreadId) return;    dispatch({      type: "SPLIT_THREAD_TERMINAL",      threadId: activeThreadId,      terminalId: `terminal-${crypto.randomUUID()}`,    });    setTerminalFocusRequestId((value) => value + 1);  }, [activeThreadId, dispatch]);  const createNewTerminal = useCallback(() => {    if (!activeThreadId) return;    dispatch({      type: "NEW_THREAD_TERMINAL",      threadId: activeThreadId,      terminalId: `terminal-${crypto.randomUUID()}`,    });    setTerminalFocusRequestId((value) => value + 1);  }, [activeThreadId, dispatch]);  const activateTerminal = useCallback(    (terminalId: string) => {      if (!activeThreadId) return;      dispatch({        type: "SET_THREAD_ACTIVE_TERMINAL",        threadId: activeThreadId,        terminalId,      });      setTerminalFocusRequestId((value) => value + 1);    },    [activeThreadId, dispatch],  );  const closeTerminal = useCallback(    (terminalId: string) => {      if (!activeThreadId || !api) return;      const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1;      const fallbackExitWrite = () =>        api.terminal          .write({ threadId: activeThreadId, terminalId, data: "exit\n" })          .catch(() => undefined);      if ("close" in api.terminal && typeof api.terminal.close === "function") {        void (async () => {          if (isFinalTerminal) {            await api.terminal              .clear({ threadId: activeThreadId, terminalId })              .catch(() => undefined);          }          await api.terminal.close({ threadId: activeThreadId, terminalId, deleteHistory: true });        })().catch(() => fallbackExitWrite());      } else {        void fallbackExitWrite();      }      dispatch({        type: "CLOSE_THREAD_TERMINAL",        threadId: activeThreadId,        terminalId,      });      setTerminalFocusRequestId((value) => value + 1);    },    [activeThread?.terminalIds.length, activeThreadId, api, dispatch],  );   const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {    if (mode === state.runtimeMode) return;    dispatch({ type: "SET_RUNTIME_MODE", mode });    if (!api) return;     const sessionIds = state.threads      .map((t) => t.session)      .filter((s): s is NonNullable<typeof s> => s !== null && s.status !== "closed")      .map((s) => s.sessionId);     if (sessionIds.length === 0) return;     setIsSwitchingRuntimeMode(true);    try {      await Promise.all(        sessionIds.map((id) => api.providers.stopSession({ sessionId: id }).catch(() => undefined)),      );    } finally {      setIsSwitchingRuntimeMode(false);    }  };   // Auto-scroll on new messages  const messageCount = activeThread?.messages.length ?? 0;  const workLogCount = workLogEntries.length;  const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {    const scrollContainer = messagesScrollRef.current;    if (!scrollContainer) return;    scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });    shouldAutoScrollRef.current = true;  }, []);  const onMessagesScroll = useCallback(() => {    const scrollContainer = messagesScrollRef.current;    if (!scrollContainer) return;    shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer);  }, []);  useLayoutEffect(() => {    if (!activeThread?.id) return;    scrollMessagesToBottom();  }, [activeThread?.id, scrollMessagesToBottom]);  useEffect(() => {    if (!shouldAutoScrollRef.current) return;    scrollMessagesToBottom("smooth");  }, [messageCount, scrollMessagesToBottom]);  useEffect(() => {    if (phase !== "running") return;    if (!shouldAutoScrollRef.current) return;    scrollMessagesToBottom("smooth");  }, [phase, workLogCount, scrollMessagesToBottom]);   useEffect(() => {    setExpandedWorkGroups({});  }, [activeThread?.id]);   useEffect(() => {    if (!activeThread?.id || activeThread.terminalOpen) return;    const frame = window.requestAnimationFrame(() => {      focusComposer();    });    return () => {      window.cancelAnimationFrame(frame);    };  }, [activeThread?.id, activeThread?.terminalOpen, focusComposer]);   useEffect(() => {    composerImagesRef.current = composerImages;  }, [composerImages]);   useEffect(() => {    setComposerImages((existing) => {      revokePreviewUrls(existing);      return [];    });    dragDepthRef.current = 0;    setIsDragOverComposer(false);    setExpandedImage(null);  }, [activeThread?.id, revokePreviewUrls]);   useEffect(() => {    return () => {      revokePreviewUrls(composerImagesRef.current);    };  }, [revokePreviewUrls]);   /**   * Close expanded image on Escape key   */  useEffect(() => {    if (!expandedImage) {      return;    }     const onKeyDown = (event: globalThis.KeyboardEvent) => {      if (event.key !== "Escape") return;      setExpandedImage(null);    };     window.addEventListener("keydown", onKeyDown);    return () => window.removeEventListener("keydown", onKeyDown);  }, [expandedImage]);   const activeWorktreePath = activeThread?.worktreePath;   useEffect(() => {    if (!activeThread?.id) return;    setEnvMode(activeWorktreePath ? "worktree" : "local");  }, [activeThread?.id, activeWorktreePath]);   // Auto-resize textarea  useEffect(() => {    const ta = textareaRef.current;    if (!ta) return;    ta.style.height = "auto";    ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;  }, [prompt]);   useEffect(() => {    if (phase !== "running") return;    const timer = window.setInterval(() => {      setNowTick(Date.now());    }, 250);    return () => {      window.clearInterval(timer);    };  }, [phase]);   useEffect(() => {    if (!activeThreadId) return;    const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;    const current = Boolean(activeThread?.terminalOpen);     if (!previous && current) {

Lines 1–500 of 1606.