1506 lines · 57.4 KB · TypeScript
RawDownload
import {  PROVIDER_SEND_TURN_MAX_INPUT_CHARS,  PROVIDER_SEND_TURN_MAX_ATTACHMENTS,  PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,  type ProviderSendTurnAttachmentInput,  type ProviderApprovalDecision,} from "@t3tools/contracts";import {  type ClipboardEvent,  type DragEvent,  type FormEvent,  Fragment,  type KeyboardEvent,  useCallback,  useEffect,  useMemo,  useRef,  useState,} from "react"; import { EDITORS, type EditorId } from "@t3tools/contracts";import { isElectron } from "../env";import { buildBootstrapInput } from "../historyBootstrap";import {  DEFAULT_MODEL,  DEFAULT_REASONING,  MODEL_OPTIONS,  REASONING_OPTIONS,  resolveModelSlug,} from "../model-logic";import {  derivePendingApprovals,  derivePhase,  deriveTimelineEntries,  deriveWorkLogEntries,  formatDuration,  formatElapsed,  formatTimestamp,} from "../session-logic";import { useStore } from "../store";import type { ChatImageAttachment } from "../types";import BranchToolbar from "./BranchToolbar";import GitActionsControl from "./GitActionsControl";import { isTerminalToggleShortcut } from "../terminal-shortcuts";import ChatMarkdown from "./ChatMarkdown";import ThreadTerminalDrawer from "./ThreadTerminalDrawer";import { useNativeApi } from "../hooks/useNativeApi"; function formatMessageMeta(createdAt: string, duration: string | null): string {  if (!duration) return formatTimestamp(createdAt);  return `${formatTimestamp(createdAt)} • ${duration}`;} const FILE_MANAGER_LABEL = navigator.platform.includes("Mac")  ? "Finder"  : navigator.platform.includes("Win")    ? "Explorer"    : "Files"; function editorLabel(editor: (typeof EDITORS)[number]): string {  return editor.command ? editor.label : FILE_MANAGER_LABEL;} 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).]"; 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 [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 [isModelMenuOpen, setIsModelMenuOpen] = useState(false);  const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);  const [lastEditor, setLastEditor] = useState<EditorId>(() => {    const stored = localStorage.getItem(LAST_EDITOR_KEY);    return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;  });  const [selectedEffort, setSelectedEffort] = useState<string>(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 messagesEndRef = useRef<HTMLDivElement>(null);  const textareaRef = useRef<HTMLTextAreaElement>(null);  const composerImagesRef = useRef<ComposerImageAttachment[]>([]);  const dragDepthRef = useRef(0);  const modelMenuRef = useRef<HTMLDivElement>(null);  const editorMenuRef = useRef<HTMLDivElement>(null);  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 modelOptions = MODEL_OPTIONS;  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 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;    const isOpen = Boolean(activeThread?.terminalOpen);    dispatch({      type: "SET_THREAD_TERMINAL_OPEN",      threadId: activeThreadId,      open: !isOpen,    });  }, [activeThread?.terminalOpen, activeThreadId, 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;  useEffect(() => {    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });  }, [messageCount]);  useEffect(() => {    if (phase !== "running") return;    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });  }, [phase, workLogCount]);   useEffect(() => {    setExpandedWorkGroups({});  }, [activeThread?.id]);   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]);   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 (!isModelMenuOpen) return;     const handleClickOutside = (event: MouseEvent) => {      if (!modelMenuRef.current) return;      if (event.target instanceof Node && !modelMenuRef.current.contains(event.target)) {        setIsModelMenuOpen(false);      }    };     window.addEventListener("mousedown", handleClickOutside);    return () => {      window.removeEventListener("mousedown", handleClickOutside);    };  }, [isModelMenuOpen]);   useEffect(() => {    if (!isEditorMenuOpen) return;     const handleClickOutside = (event: MouseEvent) => {      if (!editorMenuRef.current) return;      if (event.target instanceof Node && !editorMenuRef.current.contains(event.target)) {        setIsEditorMenuOpen(false);      }    };     window.addEventListener("mousedown", handleClickOutside);    return () => {      window.removeEventListener("mousedown", handleClickOutside);    };  }, [isEditorMenuOpen]);   // Cmd+O / Ctrl+O to open in last-used editor  useEffect(() => {    const handler = (e: globalThis.KeyboardEvent) => {      if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {        if (api && activeProject) {          e.preventDefault();          const cwd = activeThread?.worktreePath ?? activeProject.cwd;          void api.shell.openInEditor(cwd, lastEditor);        }      }    };    window.addEventListener("keydown", handler);    return () => window.removeEventListener("keydown", handler);  }, [api, activeProject, activeThread, lastEditor]);   useEffect(() => {    if (!activeThreadId) return;    const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;    const current = Boolean(activeThread?.terminalOpen);     if (!previous && current) {      setTerminalFocusRequestId((value) => value + 1);    } else if (previous && !current) {      terminalOpenByThreadRef.current[activeThreadId] = current;      const frame = window.requestAnimationFrame(() => {        focusComposer();      });      return () => {        window.cancelAnimationFrame(frame);      };    }     terminalOpenByThreadRef.current[activeThreadId] = current;  }, [activeThread?.terminalOpen, activeThreadId, focusComposer]);   useEffect(() => {    const handler = (event: globalThis.KeyboardEvent) => {      if (!activeThreadId) return;      if (!isTerminalToggleShortcut(event)) return;      event.preventDefault();      toggleTerminalVisibility();    };    window.addEventListener("keydown", handler);    return () => window.removeEventListener("keydown", handler);  }, [activeThreadId, toggleTerminalVisibility]);   const openInEditor = (editorId: EditorId) => {    if (!api || !activeProject) return;    const cwd = activeThread?.worktreePath ?? activeProject.cwd;    void api.shell.openInEditor(cwd, editorId);    setLastEditor(editorId);    localStorage.setItem(LAST_EDITOR_KEY, editorId);    setIsEditorMenuOpen(false);  };   const setThreadError = (threadId: string | null, error: string | null) => {    if (!threadId) return;    dispatch({      type: "SET_ERROR",      threadId,      error,    });  };   const addComposerImages = (files: File[]) => {    if (!activeThreadId || files.length === 0) return;     const nextImages = [...composerImagesRef.current];    let error: string | null = null;    for (const file of files) {      if (!file.type.startsWith("image/")) {        error = `Unsupported file type for '${file.name}'. Please attach image files only.`;        continue;      }      if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {        error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;        continue;      }      if (nextImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {        error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;        break;      }       const previewUrl = URL.createObjectURL(file);      nextImages.push({        type: "image",        id: crypto.randomUUID(),        name: file.name || "image",        mimeType: file.type,        sizeBytes: file.size,        previewUrl,        file,      });    }     setComposerImages(nextImages);

Lines 1–500 of 1506.