4257 lines · 158.3 KB · TypeScript
RawDownload
import {  type ApprovalRequestId,  DEFAULT_MODEL_BY_PROVIDER,  type ClaudeCodeEffort,  type MessageId,  type ProjectScript,  type ModelSlug,  type ProviderKind,  type ProjectEntry,  type ProjectId,  type ProviderApprovalDecision,  PROVIDER_SEND_TURN_MAX_ATTACHMENTS,  PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,  type ResolvedKeybindingsConfig,  type ServerProviderStatus,  type ThreadId,  type TurnId,  type EditorId,  type KeybindingCommand,  OrchestrationThreadActivity,  ProviderInteractionMode,  RuntimeMode,} from "@t3tools/contracts";import {  applyClaudePromptEffortPrefix,  getDefaultModel,  getDefaultReasoningEffort,  getReasoningEffortOptions,  isClaudeUltrathinkPrompt,  normalizeClaudeModelOptions,  normalizeCodexModelOptions,  normalizeModelSlug,  resolveReasoningEffortForProvider,  resolveModelSlugForProvider,  supportsClaudeUltrathinkKeyword,} from "@t3tools/shared/model";import { 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 { 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 {  clampCollapsedComposerCursor,  type ComposerTrigger,  collapseExpandedComposerCursor,  detectComposerTrigger,  expandCollapsedComposerCursor,  parseStandaloneComposerSlashCommand,  replaceTextRange,} from "../composer-logic";import {  derivePendingApprovals,  derivePendingUserInputs,  derivePhase,  deriveTimelineEntries,  deriveActiveWorkStartedAt,  deriveActivePlanState,  findSidebarProposedPlan,  findLatestProposedPlan,  deriveWorkLogEntries,  hasActionableProposedPlan,  hasToolActivityForTurn,  isLatestTurnSettled,  formatElapsed,} from "../session-logic";import { isScrollContainerNearBottom } from "../chat-scroll";import {  buildPendingUserInputAnswers,  derivePendingUserInputProgress,  setPendingUserInputCustomAnswer,  type PendingUserInputDraftAnswer,} from "../pendingUserInput";import { useStore } from "../store";import {  buildPlanImplementationThreadTitle,  buildPlanImplementationPrompt,  proposedPlanTitle,  resolvePlanFollowUpSubmission,} from "../proposedPlan";import { truncateTitle } from "../truncateTitle";import {  DEFAULT_INTERACTION_MODE,  DEFAULT_RUNTIME_MODE,  DEFAULT_THREAD_TERMINAL_ID,  MAX_TERMINALS_PER_GROUP,  type ChatMessage,  type TurnDiffSummary,} from "../types";import { basenameOfPath } from "../vscode-icons";import { useTheme } from "../hooks/useTheme";import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";import BranchToolbar from "./BranchToolbar";import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";import PlanSidebar from "./PlanSidebar";import ThreadTerminalDrawer from "./ThreadTerminalDrawer";import {  BotIcon,  ChevronDownIcon,  ChevronLeftIcon,  ChevronRightIcon,  CircleAlertIcon,  ListTodoIcon,  LockIcon,  LockOpenIcon,  XIcon,} from "lucide-react";import { Button } from "./ui/button";import { Separator } from "./ui/separator";import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";import { cn, randomUUID } from "~/lib/utils";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { toastManager } from "./ui/toast";import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";import { type NewProjectScriptInput } from "./ProjectScriptsControl";import {  commandForProjectScript,  nextProjectScriptId,  projectScriptRuntimeEnv,  projectScriptIdFromCommand,  setupProjectScript,} from "~/projectScripts";import { SidebarTrigger } from "./ui/sidebar";import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";import { readNativeApi } from "~/nativeApi";import { resolveAppModelSelection, useAppSettings } from "../appSettings";import { isTerminalFocused } from "../lib/terminalFocus";import {  type ComposerImageAttachment,  type DraftThreadEnvMode,  type PersistedComposerImageAttachment,  useComposerDraftStore,  useComposerThreadDraft,} from "../composerDraftStore";import {  appendTerminalContextsToPrompt,  formatTerminalContextLabel,  insertInlineTerminalContextPlaceholder,  removeInlineTerminalContextPlaceholder,  type TerminalContextDraft,  type TerminalContextSelection,} from "../lib/terminalContext";import { shouldUseCompactComposerFooter } from "./composerFooterLayout";import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";import { PullRequestThreadDialog } from "./PullRequestThreadDialog";import { MessagesTimeline } from "./chat/MessagesTimeline";import { ChatHeader } from "./chat/ChatHeader";import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker";import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker";import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";import {  buildExpiredTerminalContextToastCopy,  buildLocalDraftThread,  buildTemporaryWorktreeBranchName,  cloneComposerImageForRetry,  collectUserMessageBlobPreviewUrls,  deriveComposerSendState,  getCustomModelOptionsByProvider,  LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,  LastInvokedScriptByProjectSchema,  PullRequestDialogState,  readFileAsDataUrl,  revokeBlobPreviewUrl,  revokeUserMessagePreviewUrls,  SendPhase,} from "./ChatView.logic";import { useLocalStorage } from "~/hooks/useLocalStorage"; 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 EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; function formatOutgoingPrompt(params: {  provider: ProviderKind;  effort: string | null;  text: string;}): string {  if (params.provider === "claudeAgent" && params.effort === "ultrathink") {    return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null);  }  return params.text;}const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;const SCRIPT_TERMINAL_COLS = 120;const SCRIPT_TERMINAL_ROWS = 30; const extendReplacementRangeForTrailingSpace = (  text: string,  rangeEnd: number,  replacement: string,): number => {  if (!replacement.endsWith(" ")) {    return rangeEnd;  }  return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;}; const syncTerminalContextsByIds = (  contexts: ReadonlyArray<TerminalContextDraft>,  ids: ReadonlyArray<string>,): TerminalContextDraft[] => {  const contextsById = new Map(contexts.map((context) => [context.id, context]));  return ids.flatMap((id) => {    const context = contextsById.get(id);    return context ? [context] : [];  });}; const terminalContextIdListsEqual = (  contexts: ReadonlyArray<TerminalContextDraft>,  ids: ReadonlyArray<string>,): boolean =>  contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); interface ChatViewProps {  threadId: ThreadId;} export default function ChatView({ threadId }: ChatViewProps) {  const threads = useStore((store) => store.threads);  const projects = useStore((store) => store.projects);  const markThreadVisited = useStore((store) => store.markThreadVisited);  const syncServerReadModel = useStore((store) => store.syncServerReadModel);  const setStoreThreadError = useStore((store) => store.setError);  const setStoreThreadBranch = useStore((store) => store.setThreadBranch);  const { settings } = useAppSettings();  const timestampFormat = settings.timestampFormat;  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 composerTerminalContexts = composerDraft.terminalContexts;  const composerSendState = useMemo(    () =>      deriveComposerSendState({        prompt,        imageCount: composerImages.length,        terminalContexts: composerTerminalContexts,      }),    [composerImages.length, composerTerminalContexts, prompt],  );  const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;  const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);  const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider);  const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);  const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode);  const setComposerDraftInteractionMode = useComposerDraftStore(    (store) => store.setInteractionMode,  );  const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);  const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);  const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);  const insertComposerDraftTerminalContext = useComposerDraftStore(    (store) => store.insertTerminalContext,  );  const addComposerDraftTerminalContexts = useComposerDraftStore(    (store) => store.addTerminalContexts,  );  const removeComposerDraftTerminalContext = useComposerDraftStore(    (store) => store.removeTerminalContext,  );  const setComposerDraftTerminalContexts = useComposerDraftStore(    (store) => store.setTerminalContexts,  );  const clearComposerDraftPersistedAttachments = useComposerDraftStore(    (store) => store.clearPersistedAttachments,  );  const syncComposerDraftPersistedAttachments = useComposerDraftStore(    (store) => store.syncPersistedAttachments,  );  const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);  const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);  const getDraftThreadByProjectId = useComposerDraftStore(    (store) => store.getDraftThreadByProjectId,  );  const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);  const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);  const clearProjectDraftThreadId = useComposerDraftStore(    (store) => store.clearProjectDraftThreadId,  );  const draftThread = useComposerDraftStore(    (store) => store.draftThreadsByThreadId[threadId] ?? null,  );  const promptRef = useRef(prompt);  const [showScrollToBottom, setShowScrollToBottom] = useState(false);  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 composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts);  const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<    Record<ThreadId, string | null>  >({});  const [sendPhase, setSendPhase] = useState<SendPhase>("idle");  const [sendStartedAt, setSendStartedAt] = useState<string | null>(null);  const [isConnecting, _setIsConnecting] = useState(false);  const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);  const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);  const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<    ApprovalRequestId[]  >([]);  const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState<    Record<string, Record<string, PendingUserInputDraftAnswer>>  >({});  const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =    useState<Record<string, number>>({});  const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});  const [planSidebarOpen, setPlanSidebarOpen] = useState(false);  const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);  // Tracks whether the user explicitly dismissed the sidebar for the active turn.  const planSidebarDismissedForTurnRef = useRef<string | null>(null);  // When set, the thread-change reset effect will open the sidebar instead of closing it.  // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation.  const planSidebarOpenOnNextThreadRef = useRef(false);  const [nowTick, setNowTick] = useState(() => Date.now());  const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);  const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);  const [pullRequestDialogState, setPullRequestDialogState] =    useState<PullRequestDialogState | null>(null);  const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<    Record<string, string[]>  >({});  const [composerCursor, setComposerCursor] = useState(() =>    collapseExpandedComposerCursor(prompt, prompt.length),  );  const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>    detectComposerTrigger(prompt, prompt.length),  );  const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(    LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,    {},    LastInvokedScriptByProjectSchema,  );  const messagesScrollRef = useRef<HTMLDivElement>(null);  const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);  const shouldAutoScrollRef = useRef(true);  const lastKnownScrollTopRef = useRef(0);  const isPointerScrollActiveRef = useRef(false);  const lastTouchClientYRef = useRef<number | null>(null);  const pendingUserScrollUpIntentRef = useRef(false);  const pendingAutoScrollFrameRef = useRef<number | null>(null);  const pendingInteractionAnchorRef = useRef<{    element: HTMLElement;    top: number;  } | null>(null);  const pendingInteractionAnchorFrameRef = useRef<number | null>(null);  const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);  const composerFormRef = useRef<HTMLFormElement>(null);  const composerFormHeightRef = useRef(0);  const composerImagesRef = useRef<ComposerImageAttachment[]>([]);  const composerSelectLockRef = useRef(false);  const composerMenuOpenRef = useRef(false);  const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);  const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);  const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});  const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({});  const sendInFlightRef = useRef(false);  const dragDepthRef = useRef(0);  const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});  const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => {    messagesScrollRef.current = element;    setMessagesScrollElement(element);  }, []);   const terminalState = useTerminalStateStore((state) =>    selectThreadTerminalState(state.terminalStateByThreadId, threadId),  );  const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);  const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);  const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);  const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);  const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);  const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);   const setPrompt = useCallback(    (nextPrompt: string) => {      setComposerDraftPrompt(threadId, nextPrompt);    },    [setComposerDraftPrompt, threadId],  );  const addComposerImage = useCallback(    (image: ComposerImageAttachment) => {      addComposerDraftImage(threadId, image);    },    [addComposerDraftImage, threadId],  );  const addComposerImagesToDraft = useCallback(    (images: ComposerImageAttachment[]) => {      addComposerDraftImages(threadId, images);    },    [addComposerDraftImages, threadId],  );  const addComposerTerminalContextsToDraft = useCallback(    (contexts: TerminalContextDraft[]) => {      addComposerDraftTerminalContexts(threadId, contexts);    },    [addComposerDraftTerminalContexts, threadId],  );  const removeComposerImageFromDraft = useCallback(    (imageId: string) => {      removeComposerDraftImage(threadId, imageId);    },    [removeComposerDraftImage, threadId],  );  const removeComposerTerminalContextFromDraft = useCallback(    (contextId: string) => {      const contextIndex = composerTerminalContexts.findIndex(        (context) => context.id === contextId,      );      if (contextIndex < 0) {        return;      }      const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex);      promptRef.current = nextPrompt.prompt;      setPrompt(nextPrompt.prompt);      removeComposerDraftTerminalContext(threadId, contextId);      setComposerCursor(nextPrompt.cursor);      setComposerTrigger(        detectComposerTrigger(          nextPrompt.prompt,          expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor),        ),      );    },    [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId],  );   const serverThread = threads.find((t) => t.id === threadId);  const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);  const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);  const localDraftThread = useMemo(    () =>      draftThread        ? buildLocalDraftThread(            threadId,            draftThread,            fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex,            localDraftError,          )        : undefined,    [draftThread, fallbackDraftProject?.model, localDraftError, threadId],  );  const activeThread = serverThread ?? localDraftThread;  const runtimeMode =    composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;  const interactionMode =    composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;  const isServerThread = serverThread !== undefined;  const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;  const canCheckoutPullRequestIntoThread = isLocalDraftThread;  const diffOpen = rawSearch.diff === "1";  const activeThreadId = activeThread?.id ?? null;  const activeLatestTurn = activeThread?.latestTurn ?? null;  const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);  const activeProject = projects.find((p) => p.id === activeThread?.projectId);   const openPullRequestDialog = useCallback(    (reference?: string) => {      if (!canCheckoutPullRequestIntoThread) {        return;      }      setPullRequestDialogState({        initialReference: reference ?? null,        key: Date.now(),      });      setComposerHighlightedItemId(null);    },    [canCheckoutPullRequestIntoThread],  );   const closePullRequestDialog = useCallback(() => {    setPullRequestDialogState(null);

Lines 1–500 of 4257.