2220 lines · 77.4 KB · TypeScript
RawDownload
import {  EDITORS,  type EditorId,  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 {  DEFAULT_MODEL,  DEFAULT_REASONING,  MODEL_OPTIONS,  REASONING_OPTIONS,  ReasoningEffort,  resolveModelSlug,} from "../model-logic";import {  countDiffStat,  derivePendingApprovals,  derivePhase,  deriveTurnDiffSummaries,  deriveTimelineEntries,  inferCheckpointTurnCountByTurnId,  type TurnDiffSummary,  type PendingApproval,  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,  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 {  ChevronDownIcon,  CircleAlertIcon,  DiffIcon,  FolderClosedIcon,  InfoIcon,  LockIcon,  LockOpenIcon,  Undo2Icon,  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 { Toggle } from "./ui/toggle"; 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 [isRevertingCheckpoint, setIsRevertingCheckpoint] = 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 checkpointDiffRequestsRef = useRef(new Set<string>());  const checkpointHydrationSessionRequestRef = useRef(new Set<string>());   const activeThread = state.threads.find((t) => t.id === state.activeThreadId);  const activeThreadId = activeThread?.id ?? null;  const activeSessionId = activeThread?.session?.sessionId;  const activeThreadRuntimeId =    activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? 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 || isRevertingCheckpoint;  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 turnDiffSummaries = useMemo(    () =>      activeThread?.turnDiffSummaries.length        ? activeThread.turnDiffSummaries        : deriveTurnDiffSummaries(activeThread?.events ?? []),    [activeThread?.events, activeThread?.turnDiffSummaries],  );  const turnDiffSummaryByAssistantMessageId = useMemo(() => {    const byMessageId = new Map<string, TurnDiffSummary>();    for (const summary of turnDiffSummaries) {      if (!summary.assistantMessageId) continue;      byMessageId.set(summary.assistantMessageId, summary);    }    return byMessageId;  }, [turnDiffSummaries]);  const inferredCheckpointTurnCountByTurnId = useMemo(    () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),    [turnDiffSummaries],  );  const revertTurnCountByUserMessageId = useMemo(() => {    const byUserMessageId = new Map<string, number>();    for (let index = 0; index < timelineEntries.length; index += 1) {      const entry = timelineEntries[index];      if (!entry || entry.kind !== "message" || entry.message.role !== "user") {        continue;      }       for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) {        const nextEntry = timelineEntries[nextIndex];        if (!nextEntry || nextEntry.kind !== "message") {          continue;        }        if (nextEntry.message.role === "user") {          break;        }        const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id);        if (!summary) {          continue;        }        const turnCount =          summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];        if (typeof turnCount !== "number") {          break;        }        byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1));        break;      }    }     return byUserMessageId;  }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);   useEffect(() => {    if (!activeThreadId || turnDiffSummaries.length === 0) {      return;    }     const inferredMissingTurnCounts = turnDiffSummaries.reduce<Record<string, number>>(      (acc, summary) => {        if (typeof summary.checkpointTurnCount === "number") {          return acc;        }        const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId];        if (typeof inferredTurnCount !== "number") {          return acc;        }        acc[summary.turnId] = inferredTurnCount;        return acc;      },      {},    );    if (Object.keys(inferredMissingTurnCounts).length === 0) {      return;    }     dispatch({      type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",      threadId: activeThreadId,      checkpointTurnCountByTurnId: inferredMissingTurnCounts,    });  }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);   useEffect(() => {    if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) {      return;    }     const turnSummariesNeedingDiff = turnDiffSummaries.filter(      (summary) =>        !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId],    );    const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => {      const requestKey = `${activeThreadId}:${summary.turnId}`;      if (checkpointDiffRequestsRef.current.has(requestKey)) {        return false;      }      checkpointDiffRequestsRef.current.add(requestKey);      return true;    });    if (requestedSummaries.length === 0) {      return;    }     let cancelled = false;    void Promise.all(      requestedSummaries.map(async (summary) => {        const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId] ?? 0;        const result = await api.providers.getCheckpointDiff({          sessionId: activeSessionId,          fromTurnCount: Math.max(0, turnCount - 1),          toTurnCount: turnCount,        });        return [summary.turnId, result.diff] as const;      }),    )      .then((entries) => {        if (cancelled) return;        dispatch({          type: "SET_THREAD_TURN_CHECKPOINT_DIFFS",          threadId: activeThreadId,          checkpointDiffByTurnId: Object.fromEntries(entries),        });      })      .catch(() => {        if (cancelled) return;      })      .finally(() => {        for (const summary of requestedSummaries) {          checkpointDiffRequestsRef.current.delete(`${activeThreadId}:${summary.turnId}`);        }      });     return () => {      cancelled = true;    };  }, [    activeSessionId,    activeThreadId,    api,    dispatch,    inferredCheckpointTurnCountByTurnId,    turnDiffSummaries,  ]);   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 runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";  const runtimeSandboxMode =    state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";  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 closeTerminalShortcutLabel = useMemo(    () => shortcutLabelForCommand(keybindings, "terminal.close"),    [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 scheduleComposerFocus = useCallback(() => {    window.requestAnimationFrame(() => {      focusComposer();    });  }, [focusComposer]);  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);    },

Lines 1–500 of 2220.

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t