2855 lines · 100.8 KB · TypeScript
RawDownload
import {  type ApprovalRequestId,  DEFAULT_MODEL,  DEFAULT_REASONING,  EDITORS,  type EditorId,  type KeybindingCommand,  type MessageId,  MODEL_OPTIONS,  type ProjectId,  type ProjectEntry,  type ProjectScript,  ModelSlug,  PROVIDER_SEND_TURN_MAX_ATTACHMENTS,  PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,  REASONING_OPTIONS,  type ReasoningEffort,  type ResolvedKeybindingsConfig,  type ProviderApprovalDecision,  type ThreadId,  type TurnId,  resolveModelSlug,} from "@t3tools/contracts";import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";import { useMutation, 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 { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env";import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";import {  type ComposerTriggerKind,  detectComposerTrigger,  replaceTextRange,} from "../composer-logic";import {  derivePendingApprovals,  derivePhase,  deriveTimelineEntries,  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 ChatMessage,  type ChatImageAttachment,  type TurnDiffSummary,} 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 { 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 { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";import {  commandForProjectScript,  nextProjectScriptId,  projectScriptRuntimeEnv,  projectScriptIdFromCommand,  setupProjectScript,} from "~/projectScripts";import { Toggle } from "./ui/toggle";import { SidebarTrigger } from "./ui/sidebar";import { newCommandId, newMessageId } from "~/lib/utils";import { readNativeApi } from "~/nativeApi";import { useAppSettings } from "../appSettings"; 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";} 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;    }; type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; 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: React.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: ThreadId;} export default function ChatView({ threadId }: ChatViewProps) {  const { state, dispatch } = useStore();  const { settings } = useAppSettings();  const navigate = useNavigate();  const rawSearch = useSearch({    strict: false,    select: (params) => parseDiffRouteSearch(params),  });  const { resolvedTheme } = useTheme();  const queryClient = useQueryClient();  const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ 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 [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);  const [sendPhase, setSendPhase] = useState<SendPhase>("idle");  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<ApprovalRequestId[]>([]);  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 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 activeProject = state.projects.find((p) => p.id === activeThread?.projectId);   useEffect(() => {    if (!activeThread?.id) return;    if (!activeThread.latestTurnCompletedAt) return;    const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt);    if (Number.isNaN(turnCompletedAt)) return;    const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;    if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;     dispatch({      type: "MARK_THREAD_VISITED",      threadId: activeThread.id,    });  }, [    activeThread?.id,    activeThread?.lastVisitedAt,    activeThread?.latestTurnCompletedAt,    dispatch,  ]);   const selectedModel = resolveModelSlug(    activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,  );  const phase = derivePhase(activeThread?.session ?? null);  const isSendBusy = sendPhase !== "idle";  const isPreparingWorktree = sendPhase === "preparing-worktree";  const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;  const nowIso = new Date(nowTick).toISOString();  const threadActivities = activeThread?.activities ?? [];  const workLogEntries = useMemo(    () => deriveWorkLogEntries(threadActivities, undefined),    [threadActivities],  );  const latestTurnHasToolActivity = useMemo(() => {    const latestTurnId = activeThread?.latestTurnId;    if (!latestTurnId) {      return false;    }    return threadActivities.some(      (activity) => activity.turnId === latestTurnId && activity.tone === "tool",    );  }, [activeThread?.latestTurnId, threadActivities]);  const pendingApprovals = useMemo(    () => derivePendingApprovals(threadActivities),    [threadActivities],  );  const timelineMessages = useMemo(() => {    const serverMessages = activeThread?.messages ?? [];    if (optimisticUserMessages.length === 0) {      return serverMessages;    }    const serverIds = new Set(serverMessages.map((message) => message.id));    const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));    if (pendingMessages.length === 0) {      return serverMessages;    }    return [...serverMessages, ...pendingMessages];  }, [activeThread?.messages, optimisticUserMessages]);  const timelineEntries = useMemo(    () => deriveTimelineEntries(timelineMessages, workLogEntries),    [timelineMessages, workLogEntries],  );  const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =    useTurnDiffSummaries(activeThread);  const turnDiffSummaryByAssistantMessageId = useMemo(() => {    const byMessageId = new Map<MessageId, TurnDiffSummary>();    for (const summary of turnDiffSummaries) {      if (!summary.assistantMessageId) continue;      byMessageId.set(summary.assistantMessageId, summary);    }    return byMessageId;  }, [turnDiffSummaries]);  const revertTurnCountByUserMessageId = useMemo(() => {    const byUserMessageId = new Map<MessageId, 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]);   const completionSummary = useMemo(() => {    if (!activeThread?.latestTurnStartedAt) return null;    if (!activeThread.latestTurnCompletedAt) return null;    if (!latestTurnHasToolActivity) return null;     if (      typeof activeThread.latestTurnDurationMs === "number" &&      Number.isFinite(activeThread.latestTurnDurationMs) &&      activeThread.latestTurnDurationMs >= 0    ) {      return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`;    }     const elapsed = formatElapsed(

Lines 1–500 of 2855.