3142 lines · 109.5 KB · TypeScript
RawDownload
import {  EDITORS,  type EditorId,  type NativeApi,  type ProjectEntry,  type ProjectScript,  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,  type KeyboardEvent,  memo,  type RefObject,  useCallback,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,} from "react";import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";import { useDebouncedValue } from "@tanstack/react-pacer";import { useNavigate, useSearch } from "@tanstack/react-router";import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env";import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";import { useAppSettings } from "../appSettings";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,  deriveTurnDiffFilesFromUnifiedDiff,  deriveTimelineEntries,  type TurnDiffSummary,  type PendingApproval,  deriveWorkLogEntries,  formatDuration,  formatElapsed,  formatTimestamp,} from "../session-logic";import { isScrollContainerNearBottom } from "../chat-scroll";import { useStore } from "../store";import { truncateTitle } from "../truncateTitle";import {  DEFAULT_THREAD_TERMINAL_ID,  MAX_THREAD_TERMINAL_COUNT,  type ChatImageAttachment,  type TurnDiffFileChange,} from "../types";import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";import { useTheme } from "../hooks/useTheme";import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";import BranchToolbar from "./BranchToolbar";import GitActionsControl from "./GitActionsControl";import {  isOpenFavoriteEditorShortcut,  resolveShortcutCommand,  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,  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 { Command, CommandInput, CommandItem, CommandList } from "./ui/command";import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";import {  commandForProjectScript,  nextProjectScriptId,  projectScriptRuntimeEnv,  projectScriptIdFromCommand,  setupProjectScript,} from "~/projectScripts";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 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";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(),}));const SCRIPT_TERMINAL_COLS = 120;const SCRIPT_TERMINAL_ROWS = 30; function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {  const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);  if (!stored) return {};   try {    const parsed: unknown = JSON.parse(stored);    if (!parsed || typeof parsed !== "object") return {};    return Object.fromEntries(      Object.entries(parsed).filter(        (entry): entry is [string, string] =>          typeof entry[0] === "string" && typeof entry[1] === "string",      ),    );  } catch {    return {};  }} 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;} type TurnCheckpointFilesState =  | {      status: "pending" | "loading";      files: TurnDiffFileChange[];      errorMessage?: undefined;    }  | {      status: "error";      files: TurnDiffFileChange[];      errorMessage: string;    }  | {      status: "ready";      files: TurnDiffFileChange[];      errorMessage?: undefined;    }; function checkpointErrorMessage(error: unknown): string {  if (error instanceof Error && error.message.trim().length > 0) {    return error.message;  }  if (typeof error === "string" && error.trim().length > 0) {    return error;  }  return "Checkpoint diff is unavailable.";} 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;  resolvedTheme: "light" | "dark";  onSelect: (item: ComposerCommandItem) => void;}) {  return (    <CommandItem      value={props.item.id}      className="cursor-pointer gap-2"      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[];  resolvedTheme: "light" | "dark";  isLoading: boolean;  triggerKind: ComposerTriggerKind | null;  onHighlightedItemChange: (itemId: string | null) => void;  onSelect: (item: ComposerCommandItem) => void;  commandInputRef: RefObject<HTMLInputElement | null>;}) {  return (    <Command      mode="none"      onItemHighlighted={(highlightedValue) => {        props.onHighlightedItemChange(          typeof highlightedValue === "string" ? highlightedValue : null,        );      }}    >      <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">        <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">          <CommandInput autoFocus={false} ref={props.commandInputRef} />        </div>        <CommandList className="max-h-64">          {props.items.map((item) => (            <ComposerCommandMenuItem              key={item.id}              item={item}              resolvedTheme={props.resolvedTheme}              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>  );}); interface ChatViewProps {  threadId: string;} export default function ChatView({ threadId }: ChatViewProps) {  const { state, dispatch } = useStore();  const navigate = useNavigate();  const rawSearch = useSearch({ strict: false });  const api = useNativeApi();  const { resolvedTheme } = useTheme();  const { settings: appSettings } = useAppSettings();  const queryClient = useQueryClient();  const createWorktreeMutation = useMutation(    gitCreateWorktreeMutationOptions({ api, queryClient }),  );  const [prompt, setPrompt] = useState("");  const promptRef = useRef(prompt);  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 [composerCursor, setComposerCursor] = useState(0);  const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);  const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<    Record<string, string>  >(() => readLastInvokedScriptByProjectFromStorage());  const messagesScrollRef = useRef<HTMLDivElement>(null);  const shouldAutoScrollRef = useRef(true);  const textareaRef = useRef<HTMLTextAreaElement>(null);  const composerCommandInputRef = useRef<HTMLInputElement>(null);  const composerImagesRef = useRef<ComposerImageAttachment[]>([]);  const dragDepthRef = useRef(0);  const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});  const checkpointHydrationSessionRequestRef = useRef(new Set<string>());  const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>());   const activeThread = state.threads.find((t) => t.id === threadId);  const diffSearch = useMemo(    () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),    [rawSearch],  );  const diffOpen = diffSearch.diff === "1";  const activeThreadId = activeThread?.id ?? null;  const activeSessionId = activeThread?.session?.sessionId ?? null;  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, inferredCheckpointTurnCountByTurnId } =    useTurnDiffSummaries(activeThread);  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 checkpointDiffInputsByTurn = useMemo(    () =>      turnDiffSummaries        .filter((summary) => summary.assistantMessageId)        .map((summary) => {          const checkpointTurnCount =            summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];          return {            turnId: summary.turnId,            checkpointTurnCount:              typeof checkpointTurnCount === "number" ? checkpointTurnCount : null,          };        }),    [inferredCheckpointTurnCountByTurnId, turnDiffSummaries],  );  const checkpointDiffQueriesByTurn = useQueries({    queries: checkpointDiffInputsByTurn.map((input) =>      checkpointDiffQueryOptions(api, {        sessionId: activeSessionId,        threadRuntimeId: activeThreadRuntimeId,        fromTurnCount:          typeof input.checkpointTurnCount === "number"            ? Math.max(0, input.checkpointTurnCount - 1)            : null,        toTurnCount: input.checkpointTurnCount,        cacheScope: `turn:${input.turnId}`,      }),    ),  });

Lines 1–500 of 3142.