diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 10e6023a41..c6402ef3af 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,6 +1,6 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { useMemo } from "react"; +import { memo, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useStore } from "../store"; @@ -27,7 +27,7 @@ interface BranchToolbarProps { onEnvironmentChange?: (environmentId: EnvironmentId) => void; } -export function BranchToolbar({ +export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, draftId, @@ -101,4 +101,4 @@ export function BranchToolbar({ /> ); -} +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ffcd0cb3f5..a753a71b39 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -2,6 +2,7 @@ import { type EnvironmentId, ProjectId, type ModelSelection, + type ProviderKind, type ScopedThreadRef, type ThreadId, type TurnId, @@ -225,6 +226,17 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { ); } +export function deriveLockedProvider(input: { + thread: Thread | null | undefined; + selectedProvider: ProviderKind | null; + threadProvider: ProviderKind | null; +}): ProviderKind | null { + if (!threadHasStarted(input.thread)) { + return null; + } + return input.thread?.session?.provider ?? input.threadProvider ?? input.selectedProvider ?? null; +} + export async function waitForStartedServerThread( threadRef: ScopedThreadRef, timeoutMs = 1_000, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 578c679798..95ec82a26c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7,11 +7,8 @@ import { type ModelSelection, type ProjectScript, type ProviderKind, - type ProjectEntry, type ProjectId, type ProviderApprovalDecision, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ServerProvider, type ScopedThreadRef, type ThreadId, @@ -28,29 +25,21 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; -import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; -import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { - clampCollapsedComposerCursor, - type ComposerTrigger, collapseExpandedComposerCursor, - detectComposerTrigger, - expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, - replaceTextRange, } from "../composer-logic"; import { deriveCompletionDividerBeforeEntryId, @@ -86,7 +75,6 @@ import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, - proposedPlanTitle, resolvePlanFollowUpSubmission, } from "../proposedPlan"; import { @@ -99,31 +87,14 @@ import { type Thread, 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, - type LucideIcon, - PenLineIcon, - XIcon, -} from "lucide-react"; -import { Button } from "./ui/button"; -import { Separator } from "./ui/separator"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { ChevronDownIcon } from "lucide-react"; 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"; @@ -133,11 +104,7 @@ import { projectScriptIdFromCommand, } from "~/projectScripts"; import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; -import { - getProviderModelCapabilities, - getProviderModels, - resolveSelectableProvider, -} from "../providerModels"; +import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; @@ -150,49 +117,24 @@ import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, - type PersistedComposerImageAttachment, useComposerDraftStore, - useEffectiveComposerModelState, - useComposerThreadDraft, type DraftId, } from "../composerDraftStore"; import { appendTerminalContextsToPrompt, formatTerminalContextLabel, - insertInlineTerminalContextPlaceholder, - removeInlineTerminalContextPlaceholder, type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; -import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; -import { - resolveComposerFooterContentWidth, - shouldForceCompactComposerFooterForFit, - shouldUseCompactComposerPrimaryActions, - shouldUseCompactComposerFooter, -} from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; -import { ContextWindowMeter } from "./chat/ContextWindowMeter"; -import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; -import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; -import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; -import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; -import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; -import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; -import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; -import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; -import { - getComposerProviderState, - renderProviderTraitsMenuContent, - renderProviderTraitsPicker, -} from "./chat/composerProviderRegistry"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { @@ -200,7 +142,6 @@ import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildTemporaryWorktreeBranchName, - cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, @@ -209,12 +150,13 @@ import { LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, PullRequestDialogState, + cloneComposerImageForRetry, + deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, - threadHasStarted, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -225,12 +167,10 @@ import { } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; -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_PROPOSED_PLANS: Thread["proposedPlans"] = []; -const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; @@ -362,38 +302,9 @@ function formatOutgoingPrompt(params: { } 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, - ids: ReadonlyArray, -): 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, - ids: ReadonlyArray, -): boolean => - contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); - type ChatViewProps = | { environmentId: EnvironmentId; @@ -414,29 +325,6 @@ interface TerminalLaunchContext { worktreePath: string | null; } -const runtimeModeConfig: Record< - RuntimeMode, - { label: string; description: string; icon: LucideIcon } -> = { - "approval-required": { - label: "Supervised", - description: "Ask before commands and file changes.", - icon: LockIcon, - }, - "auto-accept-edits": { - label: "Auto-accept edits", - description: "Auto-approve edits, ask before other actions.", - icon: PenLineIcon, - }, - "full-access": { - label: "Full access", - description: "Allow commands and edits without prompts.", - icon: LockOpenIcon, - }, -}; - -const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; - type PersistentTerminalLaunchContext = Pick; function useLocalDispatchState(input: { @@ -712,53 +600,31 @@ export default function ChatView(props: ChatViewProps) { select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); - const composerDraft = useComposerThreadDraft(composerDraftTarget); - 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 setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); - 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, + // Granular store selectors — avoid subscribing to prompt changes. + const composerRuntimeMode = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null, ); - const addComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.addTerminalContexts, + const composerInteractionMode = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.interactionMode ?? null, ); - const removeComposerDraftTerminalContext = useComposerDraftStore( - (store) => store.removeTerminalContext, + const composerActiveProvider = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); + const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); + const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); - const clearComposerDraftPersistedAttachments = useComposerDraftStore( - (store) => store.clearPersistedAttachments, - ); - const syncComposerDraftPersistedAttachments = useComposerDraftStore( - (store) => store.syncPersistedAttachments, + const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); + const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); + const setComposerDraftInteractionMode = useComposerDraftStore( + (store) => store.setInteractionMode, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftSessionByLogicalProjectKey = useComposerDraftStore( (store) => store.getDraftSessionByLogicalProjectKey, ); - const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); const setLogicalProjectDraftThreadId = useComposerDraftStore( (store) => store.setLogicalProjectDraftThreadId, @@ -770,14 +636,15 @@ export default function ChatView(props: ChatViewProps) { ? store.getDraftSession(draftId) : null, ); - const promptRef = useRef(prompt); + const promptRef = useRef(""); + const composerImagesRef = useRef([]); + const composerTerminalContextsRef = useRef([]); + const composerRef = useRef(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); - const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; - const composerTerminalContextsRef = useRef(composerTerminalContexts); const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< Record >({}); @@ -794,8 +661,6 @@ export default function ChatView(props: ChatViewProps) { useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); - const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); - const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -803,7 +668,6 @@ export default function ChatView(props: ChatViewProps) { const planSidebarOpenOnNextThreadRef = useRef(false); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); - const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); const [terminalLaunchContext, setTerminalLaunchContext] = useState( @@ -812,12 +676,6 @@ export default function ChatView(props: ChatViewProps) { const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); - const [composerCursor, setComposerCursor] = useState(() => - collapseExpandedComposerCursor(prompt, prompt.length), - ); - const [composerTrigger, setComposerTrigger] = useState(() => - detectComposerTrigger(prompt, prompt.length), - ); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, @@ -836,21 +694,9 @@ export default function ChatView(props: ChatViewProps) { top: number; } | null>(null); const pendingInteractionAnchorFrameRef = useRef(null); - const composerEditorRef = useRef(null); - const composerFormRef = useRef(null); - const composerFormHeightRef = useRef(0); - const composerFooterRef = useRef(null); - const composerFooterLeadingRef = useRef(null); - const composerFooterActionsRef = useRef(null); - const composerImagesRef = useRef([]); - const composerSelectLockRef = useRef(false); - const composerMenuOpenRef = useRef(false); - const composerMenuItemsRef = useRef([]); - const activeComposerMenuItemRef = useRef(null); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); - const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { messagesScrollRef.current = element; @@ -903,59 +749,6 @@ export default function ChatView(props: ChatViewProps) { [mountedTerminalThreadKeys], ); - const setPrompt = useCallback( - (nextPrompt: string) => { - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - }, - [composerDraftTarget, setComposerDraftPrompt], - ); - const addComposerImage = useCallback( - (image: ComposerImageAttachment) => { - addComposerDraftImage(composerDraftTarget, image); - }, - [addComposerDraftImage, composerDraftTarget], - ); - const addComposerImagesToDraft = useCallback( - (images: ComposerImageAttachment[]) => { - addComposerDraftImages(composerDraftTarget, images); - }, - [addComposerDraftImages, composerDraftTarget], - ); - const addComposerTerminalContextsToDraft = useCallback( - (contexts: TerminalContextDraft[]) => { - addComposerDraftTerminalContexts(composerDraftTarget, contexts); - }, - [addComposerDraftTerminalContexts, composerDraftTarget], - ); - const removeComposerImageFromDraft = useCallback( - (imageId: string) => { - removeComposerDraftImage(composerDraftTarget, imageId); - }, - [composerDraftTarget, removeComposerDraftImage], - ); - 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(composerDraftTarget, contextId); - setComposerCursor(nextPrompt.cursor); - setComposerTrigger( - detectComposerTrigger( - nextPrompt.prompt, - expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor), - ), - ); - }, - [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt], - ); - const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; @@ -983,12 +776,9 @@ export default function ChatView(props: ChatViewProps) { ); const isServerThread = routeKind === "server" && serverThread !== undefined; const activeThread = isServerThread ? serverThread : localDraftThread; - const runtimeMode = - composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = - composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; - const runtimeModeOption = runtimeModeConfig[runtimeMode]; - const RuntimeModeIcon = runtimeModeOption.icon; + composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; @@ -1016,10 +806,6 @@ export default function ChatView(props: ChatViewProps) { return threadIds; }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), ); - const activeContextWindow = useMemo( - () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []), - [activeThread?.activities], - ); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ @@ -1103,7 +889,6 @@ export default function ChatView(props: ChatViewProps) { initialReference: reference ?? null, key: Date.now(), }); - setComposerHighlightedItemId(null); }, [canCheckoutPullRequestIntoThread], ); @@ -1215,14 +1000,14 @@ export default function ChatView(props: ChatViewProps) { serverThread?.id, ]); - const sessionProvider = activeThread?.session?.provider ?? null; - const selectedProviderByThreadId = composerDraft.activeProvider ?? null; + const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; - const hasThreadStarted = threadHasStarted(activeThread); - const lockedProvider: ProviderKind | null = hasThreadStarted - ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) - : null; + const lockedProvider = deriveLockedProvider({ + thread: activeThread, + selectedProvider: selectedProviderByThreadId, + threadProvider, + }); const primaryServerConfig = useServerConfig(); const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, @@ -1240,37 +1025,6 @@ export default function ChatView(props: ChatViewProps) { selectedProviderByThreadId ?? threadProvider ?? "codex", ); const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; - const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ - threadRef: composerDraftTarget, - providers: providerStatuses, - selectedProvider, - threadModelSelection: activeThread?.modelSelection, - projectModelSelection: activeProject?.defaultModelSelection, - settings, - }); - const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); - const composerProviderState = useMemo( - () => - getComposerProviderState({ - provider: selectedProvider, - model: selectedModel, - models: selectedProviderModels, - prompt, - modelOptions: composerModelOptions, - }), - [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], - ); - const selectedPromptEffort = composerProviderState.promptEffort; - const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; - const selectedModelSelection = useMemo( - () => ({ - provider: selectedProvider, - model: selectedModel, - ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), - }), - [selectedModel, selectedModelOptionsForDispatch, selectedProvider], - ); - const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( @@ -1372,75 +1126,6 @@ export default function ChatView(props: ChatViewProps) { activeThread?.session ?? null, localDispatchStartedAt, ); - const isComposerApprovalState = activePendingApproval !== null; - const hasComposerHeader = - isComposerApprovalState || - pendingUserInputs.length > 0 || - (showPlanFollowUpPrompt && activeProposedPlan !== null); - const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; - const composerFooterActionLayoutKey = useMemo(() => { - if (activePendingProgress) { - return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; - } - if (phase === "running") { - return "running"; - } - if (showPlanFollowUpPrompt) { - return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; - } - return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; - }, [ - activePendingIsResponding, - activePendingProgress, - composerSendState.hasSendableContent, - isConnecting, - isPreparingWorktree, - isSendBusy, - phase, - prompt, - showPlanFollowUpPrompt, - ]); - const lastSyncedPendingInputRef = useRef<{ - requestId: string | null; - questionId: string | null; - } | null>(null); - useEffect(() => { - const nextCustomAnswer = activePendingProgress?.customAnswer; - if (typeof nextCustomAnswer !== "string") { - lastSyncedPendingInputRef.current = null; - return; - } - const nextRequestId = activePendingUserInput?.requestId ?? null; - const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; - const questionChanged = - lastSyncedPendingInputRef.current?.requestId !== nextRequestId || - lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; - const textChangedExternally = promptRef.current !== nextCustomAnswer; - - lastSyncedPendingInputRef.current = { - requestId: nextRequestId, - questionId: nextQuestionId, - }; - - if (!questionChanged && !textChangedExternally) { - return; - } - - promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger( - nextCustomAnswer, - expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), - ), - ); - setComposerHighlightedItemId(null); - }, [ - activePendingProgress?.customAnswer, - activePendingUserInput?.requestId, - activePendingProgress?.activeQuestion?.id, - ]); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -1708,139 +1393,9 @@ export default function ChatView(props: ChatViewProps) { worktreePath: activeThread?.worktreePath ?? null, }) : null; - const composerTriggerKind = composerTrigger?.kind ?? null; - const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; - const isPathTrigger = composerTriggerKind === "path"; - const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( - pathTriggerQuery, - { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, - (debouncerState) => ({ isPending: debouncerState.isPending }), - ); - const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); - const modelOptionsByProvider = useMemo< - Record> - >( - () => ({ - codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], - claudeAgent: - providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], - }), - [providerStatuses], - ); - const selectedModelForPickerWithCustomFallback = useMemo(() => { - const currentOptions = modelOptionsByProvider[selectedProvider]; - return currentOptions.some((option) => option.slug === selectedModelForPicker) - ? selectedModelForPicker - : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); - }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); - const searchableModelOptions = useMemo( - () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, - ).flatMap((option) => - modelOptionsByProvider[option.value].map(({ slug, name }) => ({ - provider: option.value, - providerLabel: option.label, - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), - searchProvider: option.label.toLowerCase(), - })), - ), - [lockedProvider, modelOptionsByProvider], - ); - const workspaceEntriesQuery = useQuery( - projectSearchEntriesQueryOptions({ - environmentId, - cwd: gitCwd, - query: effectivePathQuery, - enabled: isPathTrigger, - limit: 80, - }), - ); - const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; - const composerMenuItems = useMemo(() => { - if (!composerTrigger) return []; - if (composerTrigger.kind === "path") { - return workspaceEntries.map((entry) => ({ - id: `path:${entry.kind}:${entry.path}`, - type: "path", - path: entry.path, - pathKind: entry.kind, - label: basenameOfPath(entry.path), - description: entry.parentPath ?? "", - })); - } - - if (composerTrigger.kind === "slash-command") { - const slashCommandItems = [ - { - id: "slash:model", - type: "slash-command", - command: "model", - label: "/model", - description: "Switch response model for this thread", - }, - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal build mode", - }, - ] satisfies ReadonlyArray>; - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) { - return [...slashCommandItems]; - } - return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), - ); - } - - return searchableModelOptions - .filter(({ searchSlug, searchName, searchProvider }) => { - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) return true; - return ( - searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) - ); - }) - .map(({ provider, providerLabel, slug, name }) => ({ - id: `model:${provider}:${slug}`, - type: "model", - provider, - model: slug, - label: name, - description: `${providerLabel} · ${slug}`, - })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); - const composerMenuOpen = Boolean(composerTrigger); - const activeComposerMenuItem = useMemo( - () => - composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? - composerMenuItems[0] ?? - null, - [composerHighlightedItemId, composerMenuItems], - ); - composerMenuOpenRef.current = composerMenuOpen; - composerMenuItemsRef.current = composerMenuItems; - activeComposerMenuItemRef.current = activeComposerMenuItem; - const nonPersistedComposerImageIdSet = useMemo( - () => new Set(nonPersistedComposerImageIds), - [nonPersistedComposerImageIds], - ); const activeProviderStatus = useMemo( () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], @@ -1933,29 +1488,6 @@ export default function ChatView(props: ChatViewProps) { [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], ); - // Auto-correct the draft model selection when the environment changes and - // the previously-selected provider/model is no longer available. This keeps - // the stored draft state consistent with the resolved picker values and - // prevents stale model references when the user sends a message. - const prevEnvironmentIdRef = useRef(activeThread?.environmentId); - useEffect(() => { - const currentEnvId = activeThread?.environmentId; - if (!currentEnvId || envLocked || prevEnvironmentIdRef.current === currentEnvId) { - prevEnvironmentIdRef.current = currentEnvId; - return; - } - prevEnvironmentIdRef.current = currentEnvId; - - // The resolved provider/model already account for the new environment's - // provider list. Persist that resolved selection into the draft. - if (activeThread) { - setComposerDraftModelSelection(scopeThreadRef(activeThread.environmentId, activeThread.id), { - provider: selectedProvider, - model: selectedModel, - }); - } - }, [activeThread, envLocked, selectedModel, selectedProvider, setComposerDraftModelSelection]); - const activeTerminalGroup = terminalState.terminalGroups.find( (group) => group.id === terminalState.activeTerminalGroupId, @@ -1994,55 +1526,16 @@ export default function ChatView(props: ChatViewProps) { ); const focusComposer = useCallback(() => { - composerEditorRef.current?.focusAtEnd(); + composerRef.current?.focusAtEnd(); }, []); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); - const addTerminalContextToDraft = useCallback( - (selection: TerminalContextSelection) => { - if (!activeThread) { - return; - } - const snapshot = composerEditorRef.current?.readSnapshot() ?? { - value: promptRef.current, - cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), - terminalContextIds: composerTerminalContexts.map((context) => context.id), - }; - const insertion = insertInlineTerminalContextPlaceholder( - snapshot.value, - snapshot.expandedCursor, - ); - const nextCollapsedCursor = collapseExpandedComposerCursor( - insertion.prompt, - insertion.cursor, - ); - const inserted = insertComposerDraftTerminalContext( - scopeThreadRef(activeThread.environmentId, activeThread.id), - insertion.prompt, - { - id: randomUUID(), - threadId: activeThread.id, - createdAt: new Date().toISOString(), - ...selection, - }, - insertion.contextIndex, - ); - if (!inserted) { - return; - } - promptRef.current = insertion.prompt; - setComposerCursor(nextCollapsedCursor); - setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(nextCollapsedCursor); - }); - }, - [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext], - ); + const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => { + composerRef.current?.addTerminalContext(selection); + }, []); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; @@ -2600,80 +2093,6 @@ export default function ChatView(props: ChatViewProps) { window.clearTimeout(timeout); }; }, [activeThread?.id, scheduleStickToBottom]); - useLayoutEffect(() => { - const composerForm = composerFormRef.current; - if (!composerForm) return; - const measureComposerFormWidth = () => composerForm.clientWidth; - const measureFooterCompactness = () => { - const composerFormWidth = measureComposerFormWidth(); - const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - const footer = composerFooterRef.current; - const footerStyle = footer ? window.getComputedStyle(footer) : null; - const footerContentWidth = resolveComposerFooterContentWidth({ - footerWidth: footer?.clientWidth ?? null, - paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null, - paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null, - }); - const fitInput = { - footerContentWidth, - leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null, - actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null, - }; - const nextFooterCompact = - heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput); - const nextPrimaryActionsCompact = - nextFooterCompact && - shouldUseCompactComposerPrimaryActions(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - - return { - primaryActionsCompact: nextPrimaryActionsCompact, - footerCompact: nextFooterCompact, - }; - }; - - composerFormHeightRef.current = composerForm.getBoundingClientRect().height; - const initialCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); - setIsComposerFooterCompact(initialCompactness.footerCompact); - if (typeof ResizeObserver === "undefined") return; - - const observer = new ResizeObserver((entries) => { - const [entry] = entries; - if (!entry) return; - - const nextCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact((previous) => - previous === nextCompactness.primaryActionsCompact - ? previous - : nextCompactness.primaryActionsCompact, - ); - setIsComposerFooterCompact((previous) => - previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, - ); - - const nextHeight = entry.contentRect.height; - const previousHeight = composerFormHeightRef.current; - composerFormHeightRef.current = nextHeight; - - if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }); - - observer.observe(composerForm); - return () => { - observer.disconnect(); - }; - }, [ - activeThread?.id, - composerFooterActionLayoutKey, - composerFooterHasWideActions, - scheduleStickToBottom, - ]); useEffect(() => { if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); @@ -2696,18 +2115,6 @@ export default function ChatView(props: ChatViewProps) { planSidebarDismissedForTurnRef.current = null; }, [activeThread?.id]); - useEffect(() => { - if (!composerMenuOpen) { - setComposerHighlightedItemId(null); - return; - } - setComposerHighlightedItemId((existing) => - existing && composerMenuItems.some((item) => item.id === existing) - ? existing - : (composerMenuItems[0]?.id ?? null), - ); - }, [composerMenuItems, composerMenuOpen]); - useEffect(() => { setIsRevertingCheckpoint(false); }, [activeThread?.id]); @@ -2722,14 +2129,6 @@ export default function ChatView(props: ChatViewProps) { }; }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); - useEffect(() => { - composerImagesRef.current = composerImages; - }, [composerImages]); - - useEffect(() => { - composerTerminalContextsRef.current = composerTerminalContexts; - }, [composerTerminalContexts]); - useEffect(() => { if (!activeThread?.id) return; if (activeThread.messages.length === 0) { @@ -2758,11 +2157,6 @@ export default function ChatView(props: ChatViewProps) { }; }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); - useEffect(() => { - promptRef.current = prompt; - setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); - }, [prompt]); - useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { @@ -2771,128 +2165,12 @@ export default function ChatView(props: ChatViewProps) { return []; }); resetLocalDispatch(); - setComposerHighlightedItemId(null); - setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); - setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); - dragDepthRef.current = 0; - setIsDragOverComposer(false); setExpandedImage(null); }, [draftId, resetLocalDispatch, threadId]); - useEffect(() => { - let cancelled = false; - void (async () => { - if (composerImages.length === 0) { - clearComposerDraftPersistedAttachments(composerDraftTarget); - return; - } - const getPersistedAttachmentsForThread = () => - getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; - try { - const currentPersistedAttachments = getPersistedAttachmentsForThread(); - const existingPersistedById = new Map( - currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), - ); - const stagedAttachmentById = new Map(); - await Promise.all( - composerImages.map(async (image) => { - try { - const dataUrl = await readFileAsDataUrl(image.file); - stagedAttachmentById.set(image.id, { - id: image.id, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl, - }); - } catch { - const existingPersisted = existingPersistedById.get(image.id); - if (existingPersisted) { - stagedAttachmentById.set(image.id, existingPersisted); - } - } - }), - ); - const serialized = Array.from(stagedAttachmentById.values()); - if (cancelled) { - return; - } - // Stage attachments in persisted draft state first so persist middleware can write them. - syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); - } catch { - const currentImageIds = new Set(composerImages.map((image) => image.id)); - const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); - const fallbackPersistedIds = fallbackPersistedAttachments - .map((attachment) => attachment.id) - .filter((id) => currentImageIds.has(id)); - const fallbackPersistedIdSet = new Set(fallbackPersistedIds); - const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => - fallbackPersistedIdSet.has(attachment.id), - ); - if (cancelled) { - return; - } - syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); - } - })(); - return () => { - cancelled = true; - }; - }, [ - composerDraftTarget, - clearComposerDraftPersistedAttachments, - composerImages, - getComposerDraft, - syncComposerDraftPersistedAttachments, - ]); - const closeExpandedImage = useCallback(() => { setExpandedImage(null); }, []); - const navigateExpandedImage = useCallback((direction: -1 | 1) => { - setExpandedImage((existing) => { - if (!existing || existing.images.length <= 1) { - return existing; - } - const nextIndex = - (existing.index + direction + existing.images.length) % existing.images.length; - if (nextIndex === existing.index) { - return existing; - } - return { ...existing, index: nextIndex }; - }); - }, []); - - useEffect(() => { - if (!expandedImage) { - return; - } - - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - closeExpandedImage(); - return; - } - if (expandedImage.images.length <= 1) { - return; - } - if (event.key === "ArrowLeft") { - event.preventDefault(); - event.stopPropagation(); - navigateExpandedImage(-1); - return; - } - if (event.key !== "ArrowRight") return; - event.preventDefault(); - event.stopPropagation(); - navigateExpandedImage(1); - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [closeExpandedImage, expandedImage, navigateExpandedImage]); const activeWorktreePath = activeThread?.worktreePath ?? null; const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ @@ -3097,117 +2375,6 @@ export default function ChatView(props: ChatViewProps) { toggleTerminalVisibility, ]); - const addComposerImages = (files: File[]) => { - if (!activeThreadId || files.length === 0) return; - - if (pendingUserInputs.length > 0) { - toastManager.add({ - type: "error", - title: "Attach images after answering plan questions.", - }); - return; - } - - const nextImages: ComposerImageAttachment[] = []; - let nextImageCount = composerImagesRef.current.length; - let error: string | null = null; - for (const file of files) { - if (!file.type.startsWith("image/")) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; - continue; - } - if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; - continue; - } - if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; - break; - } - - const previewUrl = URL.createObjectURL(file); - nextImages.push({ - type: "image", - id: randomUUID(), - name: file.name || "image", - mimeType: file.type, - sizeBytes: file.size, - previewUrl, - file, - }); - nextImageCount += 1; - } - - if (nextImages.length === 1 && nextImages[0]) { - addComposerImage(nextImages[0]); - } else if (nextImages.length > 1) { - addComposerImagesToDraft(nextImages); - } - setThreadError(activeThreadId, error); - }; - - const removeComposerImage = (imageId: string) => { - removeComposerImageFromDraft(imageId); - }; - - const onComposerPaste = (event: React.ClipboardEvent) => { - const files = Array.from(event.clipboardData.files); - if (files.length === 0) { - return; - } - const imageFiles = files.filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) { - return; - } - event.preventDefault(); - addComposerImages(imageFiles); - }; - - const onComposerDragEnter = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - dragDepthRef.current += 1; - setIsDragOverComposer(true); - }; - - const onComposerDragOver = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setIsDragOverComposer(true); - }; - - const onComposerDragLeave = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { - return; - } - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) { - setIsDragOverComposer(false); - } - }; - - const onComposerDrop = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - dragDepthRef.current = 0; - setIsDragOverComposer(false); - const files = Array.from(event.dataTransfer.files); - addComposerImages(files); - focusComposer(); - }; - const onRevertToTurnCount = useCallback( async (turnCount: number) => { const api = readEnvironmentApi(environmentId); @@ -3266,6 +2433,17 @@ export default function ChatView(props: ChatViewProps) { onAdvanceActivePendingUserInput(); return; } + const sendCtx = composerRef.current?.getSendContext(); + if (!sendCtx) return; + const { + images: composerImages, + terminalContexts: composerTerminalContexts, + selectedProvider: ctxSelectedProvider, + selectedModel: ctxSelectedModel, + selectedProviderModels: ctxSelectedProviderModels, + selectedPromptEffort: ctxSelectedPromptEffort, + selectedModelSelection: ctxSelectedModelSelection, + } = sendCtx; const promptForSend = promptRef.current; const { trimmedPrompt: trimmed, @@ -3283,10 +2461,8 @@ export default function ChatView(props: ChatViewProps) { planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; - clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); - setComposerHighlightedItemId(null); - setComposerCursor(0); - setComposerTrigger(null); + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); await onSubmitPlanFollowUp({ text: followUp.text, interactionMode: followUp.interactionMode, @@ -3300,10 +2476,8 @@ export default function ChatView(props: ChatViewProps) { if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; - clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); - setComposerHighlightedItemId(null); - setComposerCursor(0); - setComposerTrigger(null); + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); return; } if (!hasSendableContent) { @@ -3349,10 +2523,10 @@ export default function ChatView(props: ChatViewProps) { const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ - provider: selectedProvider, - model: selectedModel, - models: selectedProviderModels, - effort: selectedPromptEffort, + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); const turnAttachmentsPromise = Promise.all( @@ -3400,10 +2574,8 @@ export default function ChatView(props: ChatViewProps) { }); } promptRef.current = ""; - clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend)); - setComposerHighlightedItemId(null); - setComposerCursor(0); - setComposerTrigger(null); + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); let turnStartSucceeded = false; await (async () => { @@ -3426,12 +2598,14 @@ export default function ChatView(props: ChatViewProps) { } const title = truncate(titleSeed); const threadCreateModelSelection: ModelSelection = { - provider: selectedProvider, + provider: ctxSelectedProvider, model: - selectedModel || + ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL_BY_PROVIDER.codex, - ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), + ...(ctxSelectedModelSelection.options + ? { options: ctxSelectedModelSelection.options } + : {}), }; // Auto-title from first message @@ -3448,7 +2622,7 @@ export default function ChatView(props: ChatViewProps) { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, - ...(selectedModel ? { modelSelection: selectedModelSelection } : {}), + ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), runtimeMode, interactionMode, }); @@ -3495,7 +2669,7 @@ export default function ChatView(props: ChatViewProps) { text: outgoingMessageText, attachments: turnAttachments, }, - modelSelection: selectedModelSelection, + modelSelection: ctxSelectedModelSelection, titleSeed: title, runtimeMode, interactionMode, @@ -3519,11 +2693,17 @@ export default function ChatView(props: ChatViewProps) { return next.length === existing.length ? existing : next; }); promptRef.current = promptForSend; - setPrompt(promptForSend); - setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); - addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); - addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); - setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); + const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); + composerImagesRef.current = retryComposerImages; + composerTerminalContextsRef.current = composerTerminalContextsSnapshot; + setComposerDraftPrompt(composerDraftTarget, promptForSend); + addComposerDraftImages(composerDraftTarget, retryComposerImages); + setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), + prompt: promptForSend, + detectTrigger: true, + }); } setThreadError( threadIdForSend, @@ -3644,8 +2824,7 @@ export default function ChatView(props: ChatViewProps) { }; }); promptRef.current = ""; - setComposerCursor(0); - setComposerTrigger(null); + composerRef.current?.resetCursorState({ cursor: 0 }); }, [activePendingProgress?.activeQuestion, activePendingUserInput], ); @@ -3656,7 +2835,7 @@ export default function ChatView(props: ChatViewProps) { value: string, nextCursor: number, expandedCursor: number, - cursorAdjacentToMention: boolean, + _cursorAdjacentToMention: boolean, ) => { if (!activePendingUserInput) { return; @@ -3672,10 +2851,14 @@ export default function ChatView(props: ChatViewProps) { ), }, })); - setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), - ); + const snapshot = composerRef.current?.readSnapshot(); + if ( + snapshot?.value !== value || + snapshot.cursor !== nextCursor || + snapshot.expandedCursor !== expandedCursor + ) { + composerRef.current?.focusAt(nextCursor); + } }, [activePendingUserInput], ); @@ -3731,14 +2914,26 @@ export default function ChatView(props: ChatViewProps) { return; } + const sendCtx = composerRef.current?.getSendContext(); + if (!sendCtx) { + return; + } + const { + selectedProvider: ctxSelectedProvider, + selectedModel: ctxSelectedModel, + selectedProviderModels: ctxSelectedProviderModels, + selectedPromptEffort: ctxSelectedPromptEffort, + selectedModelSelection: ctxSelectedModelSelection, + } = sendCtx; + const threadIdForSend = activeThread.id; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ - provider: selectedProvider, - model: selectedModel, - models: selectedProviderModels, - effort: selectedPromptEffort, + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, text: trimmed, }); @@ -3762,7 +2957,7 @@ export default function ChatView(props: ChatViewProps) { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, - modelSelection: selectedModelSelection, + modelSelection: ctxSelectedModelSelection, runtimeMode, interactionMode: nextInteractionMode, }); @@ -3784,7 +2979,7 @@ export default function ChatView(props: ChatViewProps) { text: outgoingMessageText, attachments: [], }, - modelSelection: selectedModelSelection, + modelSelection: ctxSelectedModelSelection, titleSeed: activeThread.title, runtimeMode, interactionMode: nextInteractionMode, @@ -3829,13 +3024,8 @@ export default function ChatView(props: ChatViewProps) { persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, - selectedPromptEffort, - selectedModelSelection, - selectedProvider, - selectedProviderModels, setComposerDraftInteractionMode, setThreadError, - selectedModel, environmentId, ], ); @@ -3855,19 +3045,31 @@ export default function ChatView(props: ChatViewProps) { return; } + const sendCtx = composerRef.current?.getSendContext(); + if (!sendCtx) { + return; + } + const { + selectedProvider: ctxSelectedProvider, + selectedModel: ctxSelectedModel, + selectedProviderModels: ctxSelectedProviderModels, + selectedPromptEffort: ctxSelectedPromptEffort, + selectedModelSelection: ctxSelectedModelSelection, + } = sendCtx; + const createdAt = new Date().toISOString(); const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); const outgoingImplementationPrompt = formatOutgoingPrompt({ - provider: selectedProvider, - model: selectedModel, - models: selectedProviderModels, - effort: selectedPromptEffort, + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, text: implementationPrompt, }); const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); - const nextThreadModelSelection: ModelSelection = selectedModelSelection; + const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: false }); @@ -3901,7 +3103,7 @@ export default function ChatView(props: ChatViewProps) { text: outgoingImplementationPrompt, attachments: [], }, - modelSelection: selectedModelSelection, + modelSelection: ctxSelectedModelSelection, titleSeed: nextThreadTitle, runtimeMode, interactionMode: "default", @@ -3953,11 +3155,6 @@ export default function ChatView(props: ChatViewProps) { navigate, resetLocalDispatch, runtimeMode, - selectedPromptEffort, - selectedModelSelection, - selectedProvider, - selectedProviderModels, - selectedModel, environmentId, ]); @@ -3996,42 +3193,6 @@ export default function ChatView(props: ChatViewProps) { settings, ], ); - const setPromptFromTraits = useCallback( - (nextPrompt: string) => { - const currentPrompt = promptRef.current; - if (nextPrompt === currentPrompt) { - scheduleComposerFocus(); - return; - } - promptRef.current = nextPrompt; - setPrompt(nextPrompt); - const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); - setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); - scheduleComposerFocus(); - }, - [scheduleComposerFocus, setPrompt], - ); - const providerTraitsMenuContent = renderProviderTraitsMenuContent({ - provider: selectedProvider, - ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), - ...(routeKind === "draft" && draftId ? { draftId } : {}), - model: selectedModel, - models: selectedProviderModels, - modelOptions: composerModelOptions?.[selectedProvider], - prompt, - onPromptChange: setPromptFromTraits, - }); - const providerTraitsPicker = renderProviderTraitsPicker({ - provider: selectedProvider, - ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), - ...(routeKind === "draft" && draftId ? { draftId } : {}), - model: selectedModel, - models: selectedProviderModels, - modelOptions: composerModelOptions?.[selectedProvider], - prompt, - onPromptChange: setPromptFromTraits, - }); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { @@ -4051,257 +3212,6 @@ export default function ChatView(props: ChatViewProps) { ], ); - const applyPromptReplacement = useCallback( - ( - rangeStart: number, - rangeEnd: number, - replacement: string, - options?: { expectedText?: string }, - ): boolean => { - const currentText = promptRef.current; - const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); - const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); - if ( - options?.expectedText !== undefined && - currentText.slice(safeStart, safeEnd) !== options.expectedText - ) { - return false; - } - const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); - const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); - promptRef.current = next.text; - const activePendingQuestion = activePendingProgress?.activeQuestion; - if (activePendingQuestion && activePendingUserInput) { - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [activePendingQuestion.id]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], - next.text, - ), - }, - })); - } else { - setPrompt(next.text); - } - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), - ); - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(nextCursor); - }); - return true; - }, - [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], - ); - - const readComposerSnapshot = useCallback((): { - value: string; - cursor: number; - expandedCursor: number; - terminalContextIds: string[]; - } => { - const editorSnapshot = composerEditorRef.current?.readSnapshot(); - if (editorSnapshot) { - return editorSnapshot; - } - return { - value: promptRef.current, - cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), - terminalContextIds: composerTerminalContexts.map((context) => context.id), - }; - }, [composerCursor, composerTerminalContexts]); - - const resolveActiveComposerTrigger = useCallback((): { - snapshot: { value: string; cursor: number; expandedCursor: number }; - trigger: ComposerTrigger | null; - } => { - const snapshot = readComposerSnapshot(); - return { - snapshot, - trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), - }; - }, [readComposerSnapshot]); - - const onSelectComposerItem = useCallback( - (item: ComposerCommandItem) => { - if (composerSelectLockRef.current) return; - composerSelectLockRef.current = true; - window.requestAnimationFrame(() => { - composerSelectLockRef.current = false; - }); - const { snapshot, trigger } = resolveActiveComposerTrigger(); - if (!trigger) return; - if (item.type === "path") { - const replacement = `@${item.path} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - setComposerHighlightedItemId(null); - } - return; - } - if (item.type === "slash-command") { - if (item.command === "model") { - const replacement = "/model "; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - setComposerHighlightedItemId(null); - } - return; - } - void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - }); - if (applied) { - setComposerHighlightedItemId(null); - } - return; - } - onProviderModelSelect(item.provider, item.model); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - }); - if (applied) { - setComposerHighlightedItemId(null); - } - }, - [ - applyPromptReplacement, - handleInteractionModeChange, - onProviderModelSelect, - resolveActiveComposerTrigger, - ], - ); - const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { - setComposerHighlightedItemId(itemId); - }, []); - const nudgeComposerMenuHighlight = useCallback( - (key: "ArrowDown" | "ArrowUp") => { - if (composerMenuItems.length === 0) { - return; - } - const highlightedIndex = composerMenuItems.findIndex( - (item) => item.id === composerHighlightedItemId, - ); - const normalizedIndex = - highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; - const offset = key === "ArrowDown" ? 1 : -1; - const nextIndex = - (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; - const nextItem = composerMenuItems[nextIndex]; - setComposerHighlightedItemId(nextItem?.id ?? null); - }, - [composerHighlightedItemId, composerMenuItems], - ); - const isComposerMenuLoading = - composerTriggerKind === "path" && - ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || - workspaceEntriesQuery.isLoading || - workspaceEntriesQuery.isFetching); - - const onPromptChange = useCallback( - ( - nextPrompt: string, - nextCursor: number, - expandedCursor: number, - cursorAdjacentToMention: boolean, - terminalContextIds: string[], - ) => { - if (activePendingProgress?.activeQuestion && activePendingUserInput) { - onChangeActivePendingUserInputCustomAnswer( - activePendingProgress.activeQuestion.id, - nextPrompt, - nextCursor, - expandedCursor, - cursorAdjacentToMention, - ); - return; - } - promptRef.current = nextPrompt; - setPrompt(nextPrompt); - if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { - setComposerDraftTerminalContexts( - composerDraftTarget, - syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), - ); - } - setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), - ); - }, - [ - activePendingProgress?.activeQuestion, - activePendingUserInput, - composerTerminalContexts, - onChangeActivePendingUserInputCustomAnswer, - setPrompt, - composerDraftTarget, - setComposerDraftTerminalContexts, - ], - ); - - const onComposerCommandKey = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", - event: KeyboardEvent, - ) => { - if (key === "Tab" && event.shiftKey) { - toggleInteractionMode(); - return true; - } - - const { trigger } = resolveActiveComposerTrigger(); - const menuIsActive = composerMenuOpenRef.current || trigger !== null; - - if (menuIsActive) { - const currentItems = composerMenuItemsRef.current; - if (key === "ArrowDown" && currentItems.length > 0) { - nudgeComposerMenuHighlight("ArrowDown"); - return true; - } - if (key === "ArrowUp" && currentItems.length > 0) { - nudgeComposerMenuHighlight("ArrowUp"); - return true; - } - if (key === "Tab" || key === "Enter") { - const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; - if (selectedItem) { - onSelectComposerItem(selectedItem); - return true; - } - } - } - - if (key === "Enter" && !event.shiftKey) { - void onSend(); - return true; - } - return false; - }; const onToggleWorkGroup = useCallback((groupId: string) => { setExpandedWorkGroups((existing) => ({ ...existing, @@ -4311,7 +3221,6 @@ export default function ChatView(props: ChatViewProps) { const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); - const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { if (!isServerThread) { @@ -4333,13 +3242,16 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, isServerThread, navigate, threadId], ); - const onRevertUserMessage = (messageId: MessageId) => { - const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); - if (typeof targetTurnCount !== "number") { - return; - } - void onRevertToTurnCount(targetTurnCount); - }; + const onRevertUserMessage = useCallback( + (messageId: MessageId) => { + const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); + if (typeof targetTurnCount !== "number") { + return; + } + void onRevertToTurnCount(targetTurnCount); + }, + [onRevertToTurnCount, revertTurnCountByUserMessageId], + ); // Empty state: no active thread if (!activeThread) { @@ -4374,9 +3286,7 @@ export default function ChatView(props: ChatViewProps) { diffToggleShortcutLabel={diffPanelShortcutLabel} gitCwd={gitCwd} diffOpen={diffOpen} - onRunProjectScript={(script) => { - void runProjectScript(script); - }} + onRunProjectScript={runProjectScript} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} @@ -4456,392 +3366,71 @@ export default function ChatView(props: ChatViewProps) { {/* Input bar */}
-
-
-
- {activePendingApproval ? ( -
- -
- ) : pendingUserInputs.length > 0 ? ( -
- -
- ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
- -
- ) : null} -
- {composerMenuOpen && !isComposerApprovalState && ( -
- -
- )} - - {!isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} -
- )} - -
- - {/* Bottom toolbar */} - {activePendingApproval ? ( -
- -
- ) : ( -
-
- {/* Provider/model picker */} - - - {isComposerFooterCompact ? ( - - ) : ( - <> - {providerTraitsPicker ? ( - <> - - {providerTraitsPicker} - - ) : null} - - - - - - - - - - {activePlan || sidebarProposedPlan || planSidebarOpen ? ( - <> - - - - ) : null} - - )} -
- - {/* Right side: send / stop button */} -
- {activeContextWindow ? ( - - ) : null} - {isPreparingWorktree ? ( - - Preparing worktree... - - ) : null} - 0} - isSendBusy={isSendBusy} - isConnecting={isConnecting} - isPreparingWorktree={isPreparingWorktree} - hasSendableContent={composerSendState.hasSendableContent} - onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} - onInterrupt={() => void onInterrupt()} - onImplementPlanInNewThread={() => void onImplementPlanInNewThread()} - /> -
-
- )} -
-
-
+
{isGitRepo && ( @@ -4921,72 +3510,8 @@ export default function ChatView(props: ChatViewProps) { /> ))} - {expandedImage && expandedImageItem && ( -
- - )} -
- - {expandedImageItem.name} -

- {expandedImageItem.name} - {expandedImage.images.length > 1 - ? ` (${expandedImage.index + 1}/${expandedImage.images.length})` - : ""} -

-
- {expandedImage.images.length > 1 && ( - - )} -
+ {expandedImage && ( + )} ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6dbd5605c3..bb63db6fc0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -79,7 +79,7 @@ import { import { useGitStatus } from "../lib/gitStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; -import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; import { @@ -932,7 +932,7 @@ interface SidebarProjectItemProps { isThreadListExpanded: boolean; activeRouteThreadKey: string | null; newThreadShortcutLabel: string | null; - handleNewThread: ReturnType["handleNewThread"]; + handleNewThread: ReturnType["handleNewThread"]; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; threadJumpLabelByKey: ReadonlyMap; @@ -2033,7 +2033,7 @@ interface SidebarProjectsContentProps { handleProjectDragStart: (event: DragStartEvent) => void; handleProjectDragEnd: (event: DragEndEvent) => void; handleProjectDragCancel: (event: DragCancelEvent) => void; - handleNewThread: ReturnType["handleNewThread"]; + handleNewThread: ReturnType["handleNewThread"]; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; sortedProjects: readonly SidebarProjectSnapshot[]; @@ -2338,7 +2338,7 @@ export default function Sidebar() { const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); const { updateSettings } = useUpdateSettings(); - const { handleNewThread } = useHandleNewThread(); + const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); const routeThreadRef = useParams({ strict: false, @@ -2827,31 +2827,62 @@ export default function Sidebar() { ); const [threadJumpLabelByKey, setThreadJumpLabelByKey] = useState>(EMPTY_THREAD_JUMP_LABELS); + const threadJumpLabelsRef = useRef>(EMPTY_THREAD_JUMP_LABELS); + threadJumpLabelsRef.current = threadJumpLabelByKey; + const showThreadJumpHintsRef = useRef(showThreadJumpHints); + showThreadJumpHintsRef.current = showThreadJumpHints; const visibleThreadJumpLabelByKey = showThreadJumpHints ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; const orderedSidebarThreadKeys = visibleSidebarThreadKeys; useEffect(() => { + const clearThreadJumpHints = () => { + setThreadJumpLabelByKey((current) => + current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, + ); + updateThreadJumpHintsVisibility(false); + }; + const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + event.key !== "Meta" && + event.key !== "Control" && + event.key !== "Alt" && + event.key !== "Shift" && + !showThreadJumpHintsRef.current && + threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } const shortcutContext = getCurrentSidebarShortcutContext(); const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { platform, context: shortcutContext, }); - setThreadJumpLabelByKey((current) => { - if (!shouldShowHints) { - return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; + if (!shouldShowHints) { + if ( + showThreadJumpHintsRef.current || + threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS + ) { + clearThreadJumpHints(); } - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, + } else { + setThreadJumpLabelByKey((current) => { + const nextLabelMap = buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: shortcutContext.terminalOpen, + threadJumpCommandByKey, + }); + return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(shouldShowHints); + updateThreadJumpHintsVisibility(true); + } if (event.defaultPrevented || event.repeat) { return; @@ -2902,15 +2933,19 @@ export default function Sidebar() { }; const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } const shortcutContext = getCurrentSidebarShortcutContext(); const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { platform, context: shortcutContext, }); + if (!shouldShowHints) { + clearThreadJumpHints(); + return; + } setThreadJumpLabelByKey((current) => { - if (!shouldShowHints) { - return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; - } const nextLabelMap = buildThreadJumpLabelMap({ keybindings, platform, @@ -2919,14 +2954,11 @@ export default function Sidebar() { }); return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; }); - updateThreadJumpHintsVisibility(shouldShowHints); + updateThreadJumpHintsVisibility(true); }; const onWindowBlur = () => { - setThreadJumpLabelByKey((current) => - current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, - ); - updateThreadJumpHintsVisibility(false); + clearThreadJumpHints(); }; window.addEventListener("keydown", onWindowKeyDown); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx new file mode 100644 index 0000000000..f46ce0f788 --- /dev/null +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -0,0 +1,1899 @@ +import type { + ApprovalRequestId, + EnvironmentId, + ModelSelection, + ProjectEntry, + ProviderApprovalDecision, + ProviderInteractionMode, + ProviderKind, + RuntimeMode, + ScopedThreadRef, + ServerProvider, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { + clampCollapsedComposerCursor, + type ComposerTrigger, + collapseExpandedComposerCursor, + detectComposerTrigger, + expandCollapsedComposerCursor, + replaceTextRange, +} from "../../composer-logic"; +import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + type ComposerImageAttachment, + type DraftId, + type PersistedComposerImageAttachment, + useComposerDraftStore, + useComposerThreadDraft, + useEffectiveComposerModelState, +} from "../../composerDraftStore"; +import { + type TerminalContextDraft, + type TerminalContextSelection, + insertInlineTerminalContextPlaceholder, + removeInlineTerminalContextPlaceholder, +} from "../../lib/terminalContext"; +import { + resolveComposerFooterContentWidth, + shouldForceCompactComposerFooterForFit, + shouldUseCompactComposerPrimaryActions, + shouldUseCompactComposerFooter, +} from "../composerFooterLayout"; +import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; +import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./ProviderModelPicker"; +import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; +import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; +import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; +import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; +import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; +import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; +import { + getComposerProviderState, + renderProviderTraitsMenuContent, + renderProviderTraitsPicker, +} from "./composerProviderRegistry"; +import { ContextWindowMeter } from "./ContextWindowMeter"; +import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; +import { basenameOfPath } from "../../vscode-icons"; +import { cn, randomUUID } from "~/lib/utils"; +import { Separator } from "../ui/separator"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; +import { + BotIcon, + CircleAlertIcon, + ListTodoIcon, + type LucideIcon, + LockIcon, + LockOpenIcon, + PenLineIcon, + XIcon, +} from "lucide-react"; +import { proposedPlanTitle } from "../../proposedPlan"; +import { resolveSelectableProvider, getProviderModels } from "../../providerModels"; +import type { UnifiedSettings } from "@t3tools/contracts/settings"; +import type { SessionPhase, Thread } from "../../types"; +import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; +import type { PendingApproval, PendingUserInput } from "../../session-logic"; +import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; + +const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; + +const runtimeModeConfig: Record< + RuntimeMode, + { label: string; description: string; icon: LucideIcon } +> = { + "approval-required": { + label: "Supervised", + description: "Ask before commands and file changes.", + icon: LockIcon, + }, + "auto-accept-edits": { + label: "Auto-accept edits", + description: "Auto-approve edits, ask before other actions.", + icon: PenLineIcon, + }, + "full-access": { + label: "Full access", + description: "Allow commands and edits without prompts.", + icon: LockOpenIcon, + }, +}; + +const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; +const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; +const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; + +const extendReplacementRangeForTrailingSpace = ( + text: string, + rangeEnd: number, + replacement: string, +): number => { + if (!replacement.endsWith(" ")) { + return rangeEnd; + } + return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; +}; + +const syncTerminalContextsByIds = ( + contexts: ReadonlyArray, + ids: ReadonlyArray, +): 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, + ids: ReadonlyArray, +): boolean => + contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); + +const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { + interactionMode: ProviderInteractionMode; + runtimeMode: RuntimeMode; + showPlanToggle: boolean; + planSidebarOpen: boolean; + onToggleInteractionMode: () => void; + onRuntimeModeChange: (mode: RuntimeMode) => void; + onTogglePlanSidebar: () => void; +}) { + const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; + const RuntimeModeIcon = runtimeModeOption.icon; + + return ( + <> + + + + + + + + + {props.showPlanToggle ? ( + <> + + + + ) : null} + + ); +}); + +const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { + compact: boolean; + activeContextWindow: ReturnType; + isPreparingWorktree: boolean; + pendingAction: { + questionIndex: number; + isLastQuestion: boolean; + canAdvance: boolean; + isResponding: boolean; + isComplete: boolean; + } | null; + isRunning: boolean; + showPlanFollowUpPrompt: boolean; + promptHasText: boolean; + isSendBusy: boolean; + isConnecting: boolean; + hasSendableContent: boolean; + onPreviousPendingQuestion: () => void; + onInterrupt: () => void; + onImplementPlanInNewThread: () => void; +}) { + return ( + <> + {props.activeContextWindow ? : null} + {props.isPreparingWorktree ? ( + Preparing worktree... + ) : null} + + + ); +}); + +// -------------------------------------------------------------------------- +// Handle exposed to ChatView +// -------------------------------------------------------------------------- + +export interface ChatComposerHandle { + focusAtEnd: () => void; + focusAt: (cursor: number) => void; + readSnapshot: () => { + value: string; + cursor: number; + expandedCursor: number; + terminalContextIds: string[]; + }; + /** Reset composer cursor/trigger/highlight after external prompt mutations (e.g. onSend). */ + resetCursorState: (options?: { + cursor?: number; + prompt?: string; + detectTrigger?: boolean; + }) => void; + /** Insert a terminal context from the terminal drawer. */ + addTerminalContext: (selection: TerminalContextSelection) => void; + /** Get the current prompt/effort/model state for use in send. */ + getSendContext: () => { + prompt: string; + images: ComposerImageAttachment[]; + terminalContexts: TerminalContextDraft[]; + selectedPromptEffort: string | null; + selectedModelOptionsForDispatch: unknown; + selectedModelSelection: ModelSelection; + selectedProvider: ProviderKind; + selectedModel: string; + selectedProviderModels: ReadonlyArray; + }; +} + +// -------------------------------------------------------------------------- +// Props +// -------------------------------------------------------------------------- + +export interface ChatComposerProps { + composerDraftTarget: ScopedThreadRef | DraftId; + environmentId: EnvironmentId; + routeKind: "server" | "draft"; + routeThreadRef: ScopedThreadRef; + draftId: DraftId | null; + + // Thread context + activeThreadId: ThreadId | null; + activeThreadEnvironmentId: EnvironmentId | undefined; + activeThread: Thread | undefined; + isServerThread: boolean; + isLocalDraftThread: boolean; + + // Session phase + phase: SessionPhase; + isConnecting: boolean; + isSendBusy: boolean; + isPreparingWorktree: boolean; + + // Pending approvals / inputs + activePendingApproval: PendingApproval | null; + pendingApprovals: PendingApproval[]; + pendingUserInputs: PendingUserInput[]; + activePendingProgress: { + questionIndex: number; + isLastQuestion: boolean; + canAdvance: boolean; + customAnswer: string; + activeQuestion: { id: string } | null; + } | null; + activePendingResolvedAnswers: Record | null; + activePendingIsResponding: boolean; + activePendingDraftAnswers: Record; + activePendingQuestionIndex: number; + respondingRequestIds: ApprovalRequestId[]; + + // Plan + showPlanFollowUpPrompt: boolean; + activeProposedPlan: Thread["proposedPlans"][number] | null; + activePlan: { turnId?: TurnId } | null; + sidebarProposedPlan: { turnId?: TurnId } | null; + planSidebarOpen: boolean; + + // Mode + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + + // Provider / model + lockedProvider: ProviderKind | null; + providerStatuses: ServerProvider[]; + activeProjectDefaultModelSelection: ModelSelection | null | undefined; + activeThreadModelSelection: ModelSelection | null | undefined; + + // Context window + activeThreadActivities: Thread["activities"] | undefined; + + // Misc + resolvedTheme: "light" | "dark"; + settings: UnifiedSettings; + gitCwd: string | null; + + // Refs the parent needs kept in sync + promptRef: React.MutableRefObject; + composerImagesRef: React.MutableRefObject; + composerTerminalContextsRef: React.MutableRefObject; + + // Scroll + shouldAutoScrollRef: React.MutableRefObject; + scheduleStickToBottom: () => void; + + // Callbacks + onSend: (e?: { preventDefault: () => void }) => void; + onInterrupt: () => void; + onImplementPlanInNewThread: () => void; + onRespondToApproval: ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Promise; + onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; + onAdvanceActivePendingUserInput: () => void; + onPreviousActivePendingUserInputQuestion: () => void; + onChangeActivePendingUserInputCustomAnswer: ( + questionId: string, + value: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + ) => void; + + onProviderModelSelect: (provider: ProviderKind, model: string) => void; + toggleInteractionMode: () => void; + handleRuntimeModeChange: (mode: RuntimeMode) => void; + handleInteractionModeChange: (mode: ProviderInteractionMode) => void; + togglePlanSidebar: () => void; + + focusComposer: () => void; + scheduleComposerFocus: () => void; + setThreadError: (threadId: ThreadId | null, error: string | null) => void; + onExpandImage: (preview: ExpandedImagePreview) => void; +} + +// -------------------------------------------------------------------------- +// Component +// -------------------------------------------------------------------------- + +export const ChatComposer = memo( + forwardRef(function ChatComposer(props, ref) { + const { + composerDraftTarget, + environmentId, + routeKind, + routeThreadRef, + draftId, + activeThreadId, + activeThreadEnvironmentId: _activeThreadEnvironmentId, + activeThread, + isServerThread: _isServerThread, + isLocalDraftThread: _isLocalDraftThread, + phase, + isConnecting, + isSendBusy, + isPreparingWorktree, + activePendingApproval, + pendingApprovals, + pendingUserInputs, + activePendingProgress, + activePendingResolvedAnswers, + activePendingIsResponding, + activePendingDraftAnswers, + activePendingQuestionIndex, + respondingRequestIds, + showPlanFollowUpPrompt, + activeProposedPlan, + activePlan, + sidebarProposedPlan, + planSidebarOpen, + runtimeMode, + interactionMode, + lockedProvider, + providerStatuses, + activeProjectDefaultModelSelection, + activeThreadModelSelection, + activeThreadActivities, + resolvedTheme, + settings, + gitCwd, + promptRef, + composerImagesRef, + composerTerminalContextsRef, + shouldAutoScrollRef, + scheduleStickToBottom, + onSend, + onInterrupt, + onImplementPlanInNewThread, + onRespondToApproval, + onSelectActivePendingUserInputOption, + onAdvanceActivePendingUserInput, + onPreviousActivePendingUserInputQuestion, + onChangeActivePendingUserInputCustomAnswer, + onProviderModelSelect, + toggleInteractionMode, + handleRuntimeModeChange, + handleInteractionModeChange, + togglePlanSidebar, + focusComposer, + scheduleComposerFocus, + setThreadError, + onExpandImage, + } = props; + + // ------------------------------------------------------------------ + // Store subscriptions (prompt / images / terminal contexts) + // ------------------------------------------------------------------ + const composerDraft = useComposerThreadDraft(composerDraftTarget); + const prompt = composerDraft.prompt; + const composerImages = composerDraft.images; + const composerTerminalContexts = composerDraft.terminalContexts; + const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; + + const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); + 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 removeComposerDraftTerminalContext = useComposerDraftStore( + (store) => store.removeTerminalContext, + ); + const setComposerDraftTerminalContexts = useComposerDraftStore( + (store) => store.setTerminalContexts, + ); + const clearComposerDraftPersistedAttachments = useComposerDraftStore( + (store) => store.clearPersistedAttachments, + ); + const syncComposerDraftPersistedAttachments = useComposerDraftStore( + (store) => store.syncPersistedAttachments, + ); + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + + // ------------------------------------------------------------------ + // Model state + // ------------------------------------------------------------------ + const selectedProviderByThreadId = composerDraft.activeProvider ?? null; + const threadProvider = + activeThreadModelSelection?.provider ?? activeProjectDefaultModelSelection?.provider ?? null; + + const unlockedSelectedProvider = resolveSelectableProvider( + providerStatuses, + selectedProviderByThreadId ?? threadProvider ?? "codex", + ); + const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; + + const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ + threadRef: composerDraftTarget, + providers: providerStatuses, + selectedProvider, + threadModelSelection: activeThreadModelSelection, + projectModelSelection: activeProjectDefaultModelSelection, + settings, + }); + + const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); + + const composerProviderState = useMemo( + () => + getComposerProviderState({ + provider: selectedProvider, + model: selectedModel, + models: selectedProviderModels, + prompt, + modelOptions: composerModelOptions, + }), + [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], + ); + + const selectedPromptEffort = composerProviderState.promptEffort; + const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; + const selectedModelSelection = useMemo( + () => ({ + provider: selectedProvider, + model: selectedModel, + ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), + }), + [selectedModel, selectedModelOptionsForDispatch, selectedProvider], + ); + const selectedModelForPicker = selectedModel; + const modelOptionsByProvider = useMemo< + Record> + >( + () => ({ + codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], + claudeAgent: + providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], + }), + [providerStatuses], + ); + const selectedModelForPickerWithCustomFallback = useMemo(() => { + const currentOptions = modelOptionsByProvider[selectedProvider]; + return currentOptions.some((option) => option.slug === selectedModelForPicker) + ? selectedModelForPicker + : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); + const searchableModelOptions = useMemo( + () => + AVAILABLE_PROVIDER_OPTIONS.filter( + (option) => lockedProvider === null || option.value === lockedProvider, + ).flatMap((option) => + modelOptionsByProvider[option.value].map(({ slug, name }) => ({ + provider: option.value, + providerLabel: option.label, + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), + searchProvider: option.label.toLowerCase(), + })), + ), + [lockedProvider, modelOptionsByProvider], + ); + + // ------------------------------------------------------------------ + // Context window + // ------------------------------------------------------------------ + const activeContextWindow = useMemo( + () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), + [activeThreadActivities], + ); + + // ------------------------------------------------------------------ + // Composer-local state + // ------------------------------------------------------------------ + const [composerCursor, setComposerCursor] = useState(() => + collapseExpandedComposerCursor(prompt, prompt.length), + ); + const [composerTrigger, setComposerTrigger] = useState(() => + detectComposerTrigger(prompt, prompt.length), + ); + const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [isDragOverComposer, setIsDragOverComposer] = useState(false); + const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); + const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); + + // ------------------------------------------------------------------ + // Refs + // ------------------------------------------------------------------ + const composerEditorRef = useRef(null); + const composerFormRef = useRef(null); + const composerFormHeightRef = useRef(0); + const composerFooterRef = useRef(null); + const composerFooterLeadingRef = useRef(null); + const composerFooterActionsRef = useRef(null); + const composerSelectLockRef = useRef(false); + const composerMenuOpenRef = useRef(false); + const composerMenuItemsRef = useRef([]); + const activeComposerMenuItemRef = useRef(null); + const dragDepthRef = useRef(0); + + // ------------------------------------------------------------------ + // Derived: composer send state + // ------------------------------------------------------------------ + const composerSendState = useMemo( + () => + deriveComposerSendState({ + prompt, + imageCount: composerImages.length, + terminalContexts: composerTerminalContexts, + }), + [composerImages.length, composerTerminalContexts, prompt], + ); + + // ------------------------------------------------------------------ + // Derived: composer trigger / menu + // ------------------------------------------------------------------ + const composerTriggerKind = composerTrigger?.kind ?? null; + const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; + const isPathTrigger = composerTriggerKind === "path"; + const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( + pathTriggerQuery, + { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, + (debouncerState) => ({ isPending: debouncerState.isPending }), + ); + const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; + const workspaceEntriesQuery = useQuery( + projectSearchEntriesQueryOptions({ + environmentId, + cwd: gitCwd, + query: effectivePathQuery, + enabled: isPathTrigger, + limit: 80, + }), + ); + const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + + const composerMenuItems = useMemo(() => { + if (!composerTrigger) return []; + if (composerTrigger.kind === "path") { + return workspaceEntries.map((entry) => ({ + id: `path:${entry.kind}:${entry.path}`, + type: "path", + path: entry.path, + pathKind: entry.kind, + label: basenameOfPath(entry.path), + description: entry.parentPath ?? "", + })); + } + if (composerTrigger.kind === "slash-command") { + const slashCommandItems = [ + { + id: "slash:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch response model for this thread", + }, + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + ] satisfies ReadonlyArray>; + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) { + return [...slashCommandItems]; + } + return slashCommandItems.filter( + (item) => item.command.includes(query) || item.label.slice(1).includes(query), + ); + } + return searchableModelOptions + .filter(({ searchSlug, searchName, searchProvider }) => { + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) return true; + return ( + searchSlug.includes(query) || + searchName.includes(query) || + searchProvider.includes(query) + ); + }) + .map(({ provider, providerLabel, slug, name }) => ({ + id: `model:${provider}:${slug}`, + type: "model", + provider, + model: slug, + label: name, + description: `${providerLabel} · ${slug}`, + })); + }, [composerTrigger, searchableModelOptions, workspaceEntries]); + + const composerMenuOpen = Boolean(composerTrigger); + const activeComposerMenuItem = useMemo( + () => + composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? + composerMenuItems[0] ?? + null, + [composerHighlightedItemId, composerMenuItems], + ); + + composerMenuOpenRef.current = composerMenuOpen; + composerMenuItemsRef.current = composerMenuItems; + activeComposerMenuItemRef.current = activeComposerMenuItem; + + const nonPersistedComposerImageIdSet = useMemo( + () => new Set(nonPersistedComposerImageIds), + [nonPersistedComposerImageIds], + ); + + const isComposerApprovalState = activePendingApproval !== null; + const activePendingUserInput = pendingUserInputs[0] ?? null; + const hasComposerHeader = + isComposerApprovalState || + pendingUserInputs.length > 0 || + (showPlanFollowUpPrompt && activeProposedPlan !== null); + + const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); + const composerFooterActionLayoutKey = useMemo(() => { + if (activePendingProgress) { + return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; + } + if (phase === "running") { + return "running"; + } + if (showPlanFollowUpPrompt) { + return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; + } + return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; + }, [ + activePendingIsResponding, + activePendingProgress, + composerSendState.hasSendableContent, + isConnecting, + isPreparingWorktree, + isSendBusy, + phase, + prompt, + showPlanFollowUpPrompt, + ]); + + const isComposerMenuLoading = + composerTriggerKind === "path" && + ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || + workspaceEntriesQuery.isLoading || + workspaceEntriesQuery.isFetching); + + // ------------------------------------------------------------------ + // Provider traits UI + // ------------------------------------------------------------------ + const setPromptFromTraits = useCallback( + (nextPrompt: string) => { + const currentPrompt = promptRef.current; + if (nextPrompt === currentPrompt) { + scheduleComposerFocus(); + return; + } + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); + scheduleComposerFocus(); + }, + [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + ); + + const providerTraitsMenuContent = renderProviderTraitsMenuContent({ + provider: selectedProvider, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), + model: selectedModel, + models: selectedProviderModels, + modelOptions: composerModelOptions?.[selectedProvider], + prompt, + onPromptChange: setPromptFromTraits, + }); + const providerTraitsPicker = renderProviderTraitsPicker({ + provider: selectedProvider, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), + model: selectedModel, + models: selectedProviderModels, + modelOptions: composerModelOptions?.[selectedProvider], + prompt, + onPromptChange: setPromptFromTraits, + }); + const pendingPrimaryAction = useMemo( + () => + activePendingProgress + ? { + questionIndex: activePendingProgress.questionIndex, + isLastQuestion: activePendingProgress.isLastQuestion, + canAdvance: activePendingProgress.canAdvance, + isResponding: activePendingIsResponding, + isComplete: Boolean(activePendingResolvedAnswers), + } + : null, + [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], + ); + + // ------------------------------------------------------------------ + // Prompt helpers + // ------------------------------------------------------------------ + const setPrompt = useCallback( + (nextPrompt: string) => { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + }, + [composerDraftTarget, setComposerDraftPrompt], + ); + + const addComposerImage = useCallback( + (image: ComposerImageAttachment) => { + addComposerDraftImage(composerDraftTarget, image); + }, + [composerDraftTarget, addComposerDraftImage], + ); + + const addComposerImagesToDraft = useCallback( + (images: ComposerImageAttachment[]) => { + addComposerDraftImages(composerDraftTarget, images); + }, + [composerDraftTarget, addComposerDraftImages], + ); + + const removeComposerImageFromDraft = useCallback( + (imageId: string) => { + removeComposerDraftImage(composerDraftTarget, imageId); + }, + [composerDraftTarget, removeComposerDraftImage], + ); + + const removeComposerTerminalContextFromDraft = useCallback( + (contextId: string) => { + const contextIndex = composerTerminalContexts.findIndex( + (context) => context.id === contextId, + ); + if (contextIndex < 0) return; + const removal = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); + promptRef.current = removal.prompt; + setPrompt(removal.prompt); + removeComposerDraftTerminalContext(composerDraftTarget, contextId); + const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor); + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor)); + }, + [ + composerDraftTarget, + composerTerminalContexts, + promptRef, + removeComposerDraftTerminalContext, + setPrompt, + ], + ); + + // ------------------------------------------------------------------ + // Sync refs back to parent + // ------------------------------------------------------------------ + useEffect(() => { + promptRef.current = prompt; + setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); + }, [prompt, promptRef]); + + useEffect(() => { + composerImagesRef.current = composerImages; + }, [composerImages, composerImagesRef]); + + useEffect(() => { + composerTerminalContextsRef.current = composerTerminalContexts; + }, [composerTerminalContexts, composerTerminalContextsRef]); + + // ------------------------------------------------------------------ + // Composer menu highlight sync + // ------------------------------------------------------------------ + useEffect(() => { + if (!composerMenuOpen) { + setComposerHighlightedItemId(null); + return; + } + setComposerHighlightedItemId((existing) => + existing && composerMenuItems.some((item) => item.id === existing) + ? existing + : (composerMenuItems[0]?.id ?? null), + ); + }, [composerMenuItems, composerMenuOpen]); + + const lastSyncedPendingInputRef = useRef<{ + requestId: string | null; + questionId: string | null; + } | null>(null); + + useEffect(() => { + const nextCustomAnswer = activePendingProgress?.customAnswer; + if (typeof nextCustomAnswer !== "string") { + lastSyncedPendingInputRef.current = null; + return; + } + + const nextRequestId = activePendingUserInput?.requestId ?? null; + const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; + const questionChanged = + lastSyncedPendingInputRef.current?.requestId !== nextRequestId || + lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; + const textChangedExternally = promptRef.current !== nextCustomAnswer; + + lastSyncedPendingInputRef.current = { + requestId: nextRequestId, + questionId: nextQuestionId, + }; + + if (!questionChanged && !textChangedExternally) { + return; + } + + promptRef.current = nextCustomAnswer; + const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); + setComposerCursor(nextCursor); + setComposerTrigger( + detectComposerTrigger( + nextCustomAnswer, + expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), + ), + ); + setComposerHighlightedItemId(null); + }, [ + activePendingProgress?.customAnswer, + activePendingProgress?.activeQuestion?.id, + activePendingUserInput?.requestId, + promptRef, + ]); + + // ------------------------------------------------------------------ + // Reset compositor state on thread/draft change + // ------------------------------------------------------------------ + useEffect(() => { + setComposerHighlightedItemId(null); + setComposerCursor( + collapseExpandedComposerCursor(promptRef.current, promptRef.current.length), + ); + setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + dragDepthRef.current = 0; + setIsDragOverComposer(false); + }, [draftId, activeThreadId, promptRef]); + + // ------------------------------------------------------------------ + // Footer compact layout observation + // ------------------------------------------------------------------ + useLayoutEffect(() => { + const composerForm = composerFormRef.current; + if (!composerForm) return; + const measureComposerFormWidth = () => composerForm.clientWidth; + const measureFooterCompactness = () => { + const composerFormWidth = measureComposerFormWidth(); + const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, { + hasWideActions: composerFooterHasWideActions, + }); + const footer = composerFooterRef.current; + const footerStyle = footer ? window.getComputedStyle(footer) : null; + const footerContentWidth = resolveComposerFooterContentWidth({ + footerWidth: footer?.clientWidth ?? null, + paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null, + paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null, + }); + const fitInput = { + footerContentWidth, + leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null, + actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null, + }; + const nextFooterCompact = + heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput); + const nextPrimaryActionsCompact = + nextFooterCompact && + shouldUseCompactComposerPrimaryActions(composerFormWidth, { + hasWideActions: composerFooterHasWideActions, + }); + return { + primaryActionsCompact: nextPrimaryActionsCompact, + footerCompact: nextFooterCompact, + }; + }; + + composerFormHeightRef.current = composerForm.getBoundingClientRect().height; + const initialCompactness = measureFooterCompactness(); + setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); + setIsComposerFooterCompact(initialCompactness.footerCompact); + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver((entries) => { + const [entry] = entries; + if (!entry) return; + const nextCompactness = measureFooterCompactness(); + setIsComposerPrimaryActionsCompact((previous) => + previous === nextCompactness.primaryActionsCompact + ? previous + : nextCompactness.primaryActionsCompact, + ); + setIsComposerFooterCompact((previous) => + previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, + ); + const nextHeight = entry.contentRect.height; + const previousHeight = composerFormHeightRef.current; + composerFormHeightRef.current = nextHeight; + if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; + if (!shouldAutoScrollRef.current) return; + scheduleStickToBottom(); + }); + + observer.observe(composerForm); + return () => { + observer.disconnect(); + }; + }, [ + activeThreadId, + composerFooterActionLayoutKey, + composerFooterHasWideActions, + scheduleStickToBottom, + shouldAutoScrollRef, + ]); + + // ------------------------------------------------------------------ + // Image persist effect + // ------------------------------------------------------------------ + useEffect(() => { + let cancelled = false; + void (async () => { + if (composerImages.length === 0) { + clearComposerDraftPersistedAttachments(composerDraftTarget); + return; + } + const getPersistedAttachmentsForThread = () => + getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; + try { + const currentPersistedAttachments = getPersistedAttachmentsForThread(); + const existingPersistedById = new Map( + currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), + ); + const stagedAttachmentById = new Map(); + await Promise.all( + composerImages.map(async (image) => { + try { + const dataUrl = await readFileAsDataUrl(image.file); + stagedAttachmentById.set(image.id, { + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }); + } catch { + const existingPersisted = existingPersistedById.get(image.id); + if (existingPersisted) { + stagedAttachmentById.set(image.id, existingPersisted); + } + } + }), + ); + const serialized = Array.from(stagedAttachmentById.values()); + if (cancelled) return; + syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); + } catch { + const currentImageIds = new Set(composerImages.map((image) => image.id)); + const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); + const fallbackPersistedIds = fallbackPersistedAttachments + .map((attachment) => attachment.id) + .filter((id) => currentImageIds.has(id)); + const fallbackPersistedIdSet = new Set(fallbackPersistedIds); + const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => + fallbackPersistedIdSet.has(attachment.id), + ); + if (cancelled) return; + syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); + } + })(); + return () => { + cancelled = true; + }; + }, [ + composerDraftTarget, + clearComposerDraftPersistedAttachments, + composerImages, + getComposerDraft, + syncComposerDraftPersistedAttachments, + ]); + + // ------------------------------------------------------------------ + // Callbacks: prompt change + // ------------------------------------------------------------------ + const onPromptChange = useCallback( + ( + nextPrompt: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + terminalContextIds: string[], + ) => { + if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { + setComposerCursor(nextCursor); + setComposerTrigger( + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + ); + onChangeActivePendingUserInputCustomAnswer( + activePendingProgress.activeQuestion.id, + nextPrompt, + nextCursor, + expandedCursor, + cursorAdjacentToMention, + ); + return; + } + promptRef.current = nextPrompt; + setPrompt(nextPrompt); + if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { + setComposerDraftTerminalContexts( + composerDraftTarget, + syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), + ); + } + setComposerCursor(nextCursor); + setComposerTrigger( + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + ); + }, + [ + activePendingProgress?.activeQuestion, + pendingUserInputs.length, + onChangeActivePendingUserInputCustomAnswer, + promptRef, + setPrompt, + composerDraftTarget, + composerTerminalContexts, + setComposerDraftTerminalContexts, + ], + ); + + // ------------------------------------------------------------------ + // Callbacks: prompt replacement / menu + // ------------------------------------------------------------------ + const applyPromptReplacement = useCallback( + ( + rangeStart: number, + rangeEnd: number, + replacement: string, + options?: { expectedText?: string }, + ): boolean => { + const currentText = promptRef.current; + const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); + const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); + if ( + options?.expectedText !== undefined && + currentText.slice(safeStart, safeEnd) !== options.expectedText + ) { + return false; + } + const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); + const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); + promptRef.current = next.text; + const activePendingQuestion = activePendingProgress?.activeQuestion; + if (activePendingQuestion && activePendingUserInput) { + onChangeActivePendingUserInputCustomAnswer( + activePendingQuestion.id, + next.text, + nextCursor, + nextExpandedCursor, + false, + ); + } else { + setPrompt(next.text); + } + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(nextCursor); + }); + return true; + }, + [ + activePendingProgress?.activeQuestion, + activePendingUserInput, + onChangeActivePendingUserInputCustomAnswer, + promptRef, + setPrompt, + ], + ); + + const readComposerSnapshot = useCallback((): { + value: string; + cursor: number; + expandedCursor: number; + terminalContextIds: string[]; + } => { + const editorSnapshot = composerEditorRef.current?.readSnapshot(); + if (editorSnapshot) { + return editorSnapshot; + } + return { + value: promptRef.current, + cursor: composerCursor, + expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + terminalContextIds: composerTerminalContexts.map((context) => context.id), + }; + }, [composerCursor, composerTerminalContexts, promptRef]); + + const resolveActiveComposerTrigger = useCallback((): { + snapshot: { value: string; cursor: number; expandedCursor: number }; + trigger: ComposerTrigger | null; + } => { + const snapshot = readComposerSnapshot(); + return { + snapshot, + trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + }; + }, [readComposerSnapshot]); + + const onSelectComposerItem = useCallback( + (item: ComposerCommandItem) => { + if (composerSelectLockRef.current) return; + composerSelectLockRef.current = true; + window.requestAnimationFrame(() => { + composerSelectLockRef.current = false; + }); + const { snapshot, trigger } = resolveActiveComposerTrigger(); + if (!trigger) return; + if (item.type === "path") { + const replacement = `@${item.path} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "slash-command") { + if (item.command === "model") { + const replacement = "/model "; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + onProviderModelSelect(item.provider, item.model); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + }, + [ + applyPromptReplacement, + handleInteractionModeChange, + onProviderModelSelect, + resolveActiveComposerTrigger, + ], + ); + + const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { + setComposerHighlightedItemId(itemId); + }, []); + + const nudgeComposerMenuHighlight = useCallback( + (key: "ArrowDown" | "ArrowUp") => { + if (composerMenuItems.length === 0) return; + const highlightedIndex = composerMenuItems.findIndex( + (item) => item.id === composerHighlightedItemId, + ); + const normalizedIndex = + highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; + const offset = key === "ArrowDown" ? 1 : -1; + const nextIndex = + (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; + const nextItem = composerMenuItems[nextIndex]; + setComposerHighlightedItemId(nextItem?.id ?? null); + }, + [composerHighlightedItemId, composerMenuItems], + ); + + // ------------------------------------------------------------------ + // Callbacks: command key + // ------------------------------------------------------------------ + const onComposerCommandKey = ( + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + event: KeyboardEvent, + ) => { + if (key === "Tab" && event.shiftKey) { + toggleInteractionMode(); + return true; + } + const { trigger } = resolveActiveComposerTrigger(); + const menuIsActive = composerMenuOpenRef.current || trigger !== null; + if (menuIsActive) { + const currentItems = composerMenuItemsRef.current; + const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; + if (key === "ArrowDown" && currentItems.length > 0) { + nudgeComposerMenuHighlight("ArrowDown"); + return true; + } + if (key === "ArrowUp" && currentItems.length > 0) { + nudgeComposerMenuHighlight("ArrowUp"); + return true; + } + if ((key === "Enter" || key === "Tab") && selectedItem) { + onSelectComposerItem(selectedItem); + return true; + } + } + if (key === "Enter" && !event.shiftKey) { + void onSend(); + return true; + } + return false; + }; + + // ------------------------------------------------------------------ + // Callbacks: images + // ------------------------------------------------------------------ + const addComposerImages = (files: File[]) => { + if (!activeThreadId || files.length === 0) return; + if (pendingUserInputs.length > 0) { + toastManager.add({ + type: "error", + title: "Attach images after answering plan questions.", + }); + return; + } + const nextImages: ComposerImageAttachment[] = []; + let nextImageCount = composerImagesRef.current.length; + let error: string | null = null; + for (const file of files) { + if (!file.type.startsWith("image/")) { + error = `Unsupported file type for '${file.name}'. Please attach image files only.`; + continue; + } + if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; + continue; + } + if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; + break; + } + const previewUrl = URL.createObjectURL(file); + nextImages.push({ + type: "image", + id: randomUUID(), + name: file.name || "image", + mimeType: file.type, + sizeBytes: file.size, + previewUrl, + file, + }); + nextImageCount += 1; + } + if (nextImages.length === 1 && nextImages[0]) { + addComposerImage(nextImages[0]); + } else if (nextImages.length > 1) { + addComposerImagesToDraft(nextImages); + } + setThreadError(activeThreadId, error); + }; + + const removeComposerImage = (imageId: string) => { + removeComposerImageFromDraft(imageId); + }; + + // ------------------------------------------------------------------ + // Callbacks: paste / drag + // ------------------------------------------------------------------ + const onComposerPaste = (event: React.ClipboardEvent) => { + const files = Array.from(event.clipboardData.files); + if (files.length === 0) return; + const imageFiles = files.filter((file) => file.type.startsWith("image/")); + if (imageFiles.length === 0) return; + event.preventDefault(); + addComposerImages(imageFiles); + }; + + const onComposerDragEnter = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + dragDepthRef.current += 1; + setIsDragOverComposer(true); + }; + + const onComposerDragOver = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setIsDragOverComposer(true); + }; + + const onComposerDragLeave = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsDragOverComposer(false); + } + }; + + const onComposerDrop = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + dragDepthRef.current = 0; + setIsDragOverComposer(false); + const files = Array.from(event.dataTransfer.files); + addComposerImages(files); + focusComposer(); + }; + const handleInterruptPrimaryAction = useCallback(() => { + void onInterrupt(); + }, [onInterrupt]); + const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { + void onImplementPlanInNewThread(); + }, [onImplementPlanInNewThread]); + + // ------------------------------------------------------------------ + // Imperative handle + // ------------------------------------------------------------------ + useImperativeHandle( + ref, + () => ({ + focusAtEnd: () => { + composerEditorRef.current?.focusAtEnd(); + }, + focusAt: (cursor: number) => { + composerEditorRef.current?.focusAt(cursor); + }, + readSnapshot: () => { + return readComposerSnapshot(); + }, + resetCursorState: (options?: { + cursor?: number; + prompt?: string; + detectTrigger?: boolean; + }) => { + const promptForState = options?.prompt ?? promptRef.current; + const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); + setComposerHighlightedItemId(null); + setComposerCursor(cursor); + setComposerTrigger( + options?.detectTrigger + ? detectComposerTrigger( + promptForState, + expandCollapsedComposerCursor(promptForState, cursor), + ) + : null, + ); + }, + addTerminalContext: (selection: TerminalContextSelection) => { + if (!activeThread) return; + const snapshot = composerEditorRef.current?.readSnapshot() ?? { + value: promptRef.current, + cursor: composerCursor, + expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + terminalContextIds: composerTerminalContexts.map((context) => context.id), + }; + const insertion = insertInlineTerminalContextPlaceholder( + snapshot.value, + snapshot.expandedCursor, + ); + const nextCollapsedCursor = collapseExpandedComposerCursor( + insertion.prompt, + insertion.cursor, + ); + const inserted = insertComposerDraftTerminalContext( + composerDraftTarget, + insertion.prompt, + { + id: randomUUID(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + ...selection, + }, + insertion.contextIndex, + ); + if (!inserted) return; + promptRef.current = insertion.prompt; + setComposerCursor(nextCollapsedCursor); + setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(nextCollapsedCursor); + }); + }, + getSendContext: () => ({ + prompt: promptRef.current, + images: composerImagesRef.current, + terminalContexts: composerTerminalContextsRef.current, + selectedPromptEffort, + selectedModelOptionsForDispatch, + selectedModelSelection, + selectedProvider, + selectedModel, + selectedProviderModels, + }), + }), + [ + activeThread, + composerDraftTarget, + composerCursor, + composerTerminalContexts, + insertComposerDraftTerminalContext, + promptRef, + composerImagesRef, + composerTerminalContextsRef, + readComposerSnapshot, + selectedModel, + selectedModelOptionsForDispatch, + selectedModelSelection, + selectedPromptEffort, + selectedProvider, + selectedProviderModels, + ], + ); + + // Render + // ------------------------------------------------------------------ + return ( +
+
+
+ {activePendingApproval ? ( +
+ +
+ ) : pendingUserInputs.length > 0 ? ( +
+ +
+ ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
+ +
+ ) : null} + +
+ {composerMenuOpen && !isComposerApprovalState && ( +
+ +
+ )} + + {!isComposerApprovalState && + pendingUserInputs.length === 0 && + composerImages.length > 0 && ( +
+ {composerImages.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))} +
+ )} + + +
+ + {/* Bottom toolbar */} + {activePendingApproval ? ( +
+ +
+ ) : ( +
+
+ + + {isComposerFooterCompact ? ( + + ) : ( + <> + {providerTraitsPicker ? ( + <> + + {providerTraitsPicker} + + ) : null} + + + )} +
+ + {/* Right side: send / stop button */} +
+ 0} + isSendBusy={isSendBusy} + isConnecting={isConnecting} + isPreparingWorktree={isPreparingWorktree} + hasSendableContent={composerSendState.hasSendableContent} + onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} + onInterrupt={handleInterruptPrimaryAction} + onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + /> +
+
+ )} +
+
+
+ ); + }), +); diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx new file mode 100644 index 0000000000..10031b48cd --- /dev/null +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -0,0 +1,120 @@ +import { memo, useCallback, useEffect, useState } from "react"; +import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import type { ExpandedImagePreview } from "./ExpandedImagePreview"; + +interface ExpandedImageDialogProps { + preview: ExpandedImagePreview; + onClose: () => void; +} + +export const ExpandedImageDialog = memo(function ExpandedImageDialog({ + preview: initialPreview, + onClose, +}: ExpandedImageDialogProps) { + const [preview, setPreview] = useState(initialPreview); + + // Sync when the parent hands us a new preview reference. + useEffect(() => { + setPreview(initialPreview); + }, [initialPreview]); + + const navigateImage = useCallback((direction: -1 | 1) => { + setPreview((existing) => { + if (existing.images.length <= 1) return existing; + const nextIndex = + (existing.index + direction + existing.images.length) % existing.images.length; + if (nextIndex === existing.index) return existing; + return { ...existing, index: nextIndex }; + }); + }, []); + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + return; + } + if (preview.images.length <= 1) return; + if (event.key === "ArrowLeft") { + event.preventDefault(); + event.stopPropagation(); + navigateImage(-1); + return; + } + if (event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + navigateImage(1); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [navigateImage, onClose, preview.images.length]); + + const item = preview.images[preview.index]; + if (!item) return null; + + return ( +
+ + )} +
+ + {item.name} +

+ {item.name} + {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""} +

+
+ {preview.images.length > 1 && ( + + )} +
+ ); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index b40d06b085..2518ef8745 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -38,6 +38,7 @@ import { } from "./lib/terminalContext"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { useShallow } from "zustand/react/shallow"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; import { getDefaultServerModel } from "./providerModels"; import { UnifiedSettings } from "@t3tools/contracts/settings"; @@ -378,6 +379,11 @@ export interface EffectiveComposerModelState { modelOptions: ProviderModelOptions | null; } +interface ComposerDraftModelState { + activeProvider: ProviderKind | null; + modelSelectionByProvider: Partial>; +} + function providerModelOptionsFromSelection( modelSelection: ModelSelection | null | undefined, ): ProviderModelOptions | null { @@ -420,6 +426,10 @@ Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> = Object.freeze({}); +const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze({ + activeProvider: null, + modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, +}); const EMPTY_THREAD_DRAFT = Object.freeze({ prompt: "", @@ -2764,6 +2774,22 @@ export function useComposerThreadDraft(threadRef: ComposerThreadTarget): Compose }); } +export function useComposerDraftModelState( + threadRef: ComposerThreadTarget, +): ComposerDraftModelState { + return useComposerDraftStore( + useShallow((state) => { + const draft = getComposerDraftState(state, threadRef); + return draft + ? { + activeProvider: draft.activeProvider, + modelSelectionByProvider: draft.modelSelectionByProvider, + } + : EMPTY_COMPOSER_DRAFT_MODEL_STATE; + }), + ); +} + export function useEffectiveComposerModelState(input: { threadRef?: ComposerThreadTarget; draftId?: DraftId; @@ -2773,7 +2799,9 @@ export function useEffectiveComposerModelState(input: { projectModelSelection: ModelSelection | null | undefined; settings: UnifiedSettings; }): EffectiveComposerModelState { - const draft = useComposerThreadDraft(input.threadRef ?? input.draftId ?? DraftId.makeUnsafe("")); + const draft = useComposerDraftModelState( + input.threadRef ?? input.draftId ?? DraftId.makeUnsafe(""), + ); return useMemo( () => diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ed338e7f38..6856140daa 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -16,39 +16,15 @@ import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; -export function useHandleNewThread() { +function useNewThreadState() { const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); - const projectOrder = useUiStateStore((store) => store.projectOrder); const router = useRouter(); - const routeTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); - const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; - const activeThread = useStore( - useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), - ); - const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const activeDraftThread = useComposerDraftStore(() => - routeTarget - ? routeTarget.kind === "server" - ? getDraftThread(routeTarget.threadRef) - : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) - : null, - ); - const orderedProjects = useMemo(() => { - return orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - }); - }, [projectOrder, projects]); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; return resolveThreadRouteTarget(currentRouteParams); }, [router]); - const handleNewThread = useCallback( + return useCallback( ( projectRef: ScopedProjectRef, options?: { @@ -154,6 +130,43 @@ export function useHandleNewThread() { }, [getCurrentRouteTarget, router, projects], ); +} + +export function useNewThreadHandler() { + const handleNewThread = useNewThreadState(); + + return { + handleNewThread, + }; +} + +export function useHandleNewThread() { + const projectOrder = useUiStateStore((store) => store.projectOrder); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; + const activeThread = useStore( + useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), + ); + const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); + const activeDraftThread = useComposerDraftStore(() => + routeTarget + ? routeTarget.kind === "server" + ? getDraftThread(routeTarget.threadRef) + : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) + : null, + ); + const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + }); + }, [projectOrder, projects]); + const handleNewThread = useNewThreadState(); return { activeDraftThread, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 7087002bcd..4835edd4d1 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -6,7 +6,7 @@ import { useCallback } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; -import { useHandleNewThread } from "./useHandleNewThread"; +import { useNewThreadHandler } from "./useHandleNewThread"; import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; import { invalidateGitQueries } from "../lib/gitReactQuery"; import { newCommandId } from "../lib/utils"; @@ -32,7 +32,7 @@ export function useThreadActions() { ); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); const router = useRouter(); - const { handleNewThread } = useHandleNewThread(); + const { handleNewThread } = useNewThreadHandler(); const queryClient = useQueryClient(); const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {