3833 lines · 137.4 KB · TypeScript
RawDownload
import {  type ApprovalRequestId,  DEFAULT_MODEL,  DEFAULT_REASONING,  EDITORS,  type EditorId,  type KeybindingCommand,  type MessageId,  type ProjectId,  type ProjectEntry,  type ProjectScript,  PROVIDER_SEND_TURN_MAX_ATTACHMENTS,  PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,  REASONING_OPTIONS,  type ReasoningEffort,  type ResolvedKeybindingsConfig,  type ProviderApprovalDecision,  type ServerProviderStatus,  type ThreadId,  type TurnId,  normalizeModelSlug,  resolveModelSlug,  OrchestrationThreadActivity,} 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 {  measureElement as measureVirtualElement,  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 ComposerTrigger,  type ComposerTriggerKind,  detectComposerTrigger,  expandCollapsedComposerCursor,  replaceTextRange,} from "../composer-logic";import {  derivePendingApprovals,  derivePhase,  deriveTimelineEntries,  type PendingApproval,  deriveWorkLogEntries,  hasToolActivityForTurn,  isLatestTurnSettled,  formatElapsed,  formatTimestamp,} from "../session-logic";import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll";import { useStore } from "../store";import { truncateTitle } from "../truncateTitle";import {  DEFAULT_THREAD_TERMINAL_ID,  MAX_THREAD_TERMINAL_COUNT,  type ChatMessage,  type Thread,  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,  ChevronLeftIcon,  ChevronRightIcon,  CircleAlertIcon,  FileIcon,  FolderIcon,  DiffIcon,  FolderClosedIcon,  InfoIcon,  LockIcon,  LockOpenIcon,  Undo2Icon,  XIcon,  CopyIcon,  CheckIcon,} 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, VisualStudioCode, Zed } from "./Icons";import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";import { Badge } from "./ui/badge";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { Command, 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 { getAppModelOptions, getSlashModelOptions, useAppSettings } from "../appSettings";import {  type ComposerImageAttachment,  type DraftThreadEnvMode,  type DraftThreadState,  type PersistedComposerImageAttachment,  useComposerDraftStore,  useComposerThreadDraft,} from "../composerDraftStore";import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";import { clamp } from "effect/Number";import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; 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 ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;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_ACTIVITIES: OrchestrationThreadActivity[] = [];const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;const SCRIPT_TERMINAL_COLS = 120;const SCRIPT_TERMINAL_ROWS = 30;const WORKTREE_BRANCH_PREFIX = "t3code"; 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 ExpandedImageItem {  src: string;  name: string;} interface ExpandedImagePreview {  images: ExpandedImageItem[];  index: number;} function buildExpandedImagePreview(  images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>,  selectedImageId: string,): ExpandedImagePreview | null {  const previewableImages = images.flatMap((image) =>    image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [],  );  if (previewableImages.length === 0) {    return null;  }  const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId);  if (selectedIndex < 0) {    return null;  }  return {    images: previewableImages.map((image) => ({ src: image.src, name: image.name })),    index: selectedIndex,  };} function buildLocalDraftThread(  threadId: ThreadId,  draftThread: DraftThreadState,  fallbackModel: string,  error: string | null,): Thread {  return {    id: threadId,    codexThreadId: null,    projectId: draftThread.projectId,    title: "New thread",    model: fallbackModel,    session: null,    messages: [],    error,    createdAt: draftThread.createdAt,    latestTurn: null,    lastVisitedAt: draftThread.createdAt,    branch: draftThread.branch,    worktreePath: draftThread.worktreePath,    turnDiffSummaries: [],    activities: [],  };} function revokeBlobPreviewUrl(previewUrl: string | undefined): void {  if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {    return;  }  URL.revokeObjectURL(previewUrl);} function revokeUserMessagePreviewUrls(message: ChatMessage): void {  if (message.role !== "user" || !message.attachments) {    return;  }  for (const attachment of message.attachments) {    if (attachment.type !== "image") {      continue;    }    revokeBlobPreviewUrl(attachment.previewUrl);  }} function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] {  if (message.role !== "user" || !message.attachments) {    return [];  }  const previewUrls: string[] = [];  for (const attachment of message.attachments) {    if (attachment.type !== "image") continue;    if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue;    previewUrls.push(attachment.previewUrl);  }  return previewUrls;} 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: string;      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);  });} function buildTemporaryWorktreeBranchName(): string {  // Keep the 8-hex suffix shape for backend temporary-branch detection.  const token = crypto.randomUUID().slice(0, 8).toLowerCase();  return `${WORKTREE_BRANCH_PREFIX}/${token}`;} function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerImageAttachment {  if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) {    return image;  }  try {    return {      ...image,      previewUrl: URL.createObjectURL(image.file),    };  } catch {    return image;  }} const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {  pathValue: string;  kind: "file" | "directory";  theme: "light" | "dark";}) {  const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null);  const iconUrl = useMemo(    () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme),    [props.kind, props.pathValue, props.theme],  );  const failed = failedIconUrl === 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={() => setFailedIconUrl(iconUrl)}    />  );}); const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {  item: ComposerCommandItem;  resolvedTheme: "light" | "dark";  isActive: boolean;  onSelect: (item: ComposerCommandItem) => void;}) {  return (    <CommandItem      value={props.item.id}      className={cn(        "cursor-pointer select-none gap-2",        props.isActive && "bg-accent text-accent-foreground",      )}      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;  activeItemId: string | null;  onHighlightedItemChange: (itemId: string | null) => void;  onSelect: (item: ComposerCommandItem) => void;}) {  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">        <CommandList className="max-h-64">          {props.items.map((item) => (            <ComposerCommandMenuItem              key={item.id}              item={item}              resolvedTheme={props.resolvedTheme}              isActive={props.activeItemId === item.id}              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 threads = useStore((store) => store.threads);  const projects = useStore((store) => store.projects);  const runtimeMode = useStore((store) => store.runtimeMode);  const markThreadVisited = useStore((store) => store.markThreadVisited);  const setStoreThreadError = useStore((store) => store.setError);  const setStoreRuntimeMode = useStore((store) => store.setRuntimeMode);  const setStoreThreadBranch = useStore((store) => store.setThreadBranch);  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 composerDraft = useComposerThreadDraft(threadId);  const prompt = composerDraft.prompt;  const composerImages = composerDraft.images;  const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;  const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);  const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);  const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort);  const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);  const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);  const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);  const clearComposerDraftPersistedAttachments = useComposerDraftStore(    (store) => store.clearPersistedAttachments,  );  const syncComposerDraftPersistedAttachments = useComposerDraftStore(    (store) => store.syncPersistedAttachments,  );  const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);  const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);  const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);  const draftThread = useComposerDraftStore(    (store) => store.draftThreadsByThreadId[threadId] ?? null,  );  const promptRef = useRef(prompt);  const [isDragOverComposer, setIsDragOverComposer] = useState(false);  const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);  const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);  const optimisticUserMessagesRef = useRef(optimisticUserMessages);  optimisticUserMessagesRef.current = optimisticUserMessages;  const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<    Record<ThreadId, string | null>  >({});

Lines 1–500 of 3833.