| author | |
| committer | |
| log | 869789b4edd7ff71ed717feb1dc1935aef36e5ad |
| tree | 98fede81cc1f0836551ee7fb120c4e38f0127715 |
| parent | 9bedd7140ce22f7f05d3990cdb50cafcceae37de |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>9 files changed, 2359 insertions(+), 1730 deletions(-)
apps/web/src/components/BranchToolbar.tsx+3-3| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { useMemo } from "react"; | |
| 3 | import { memo, useMemo } from "react"; | |
| 4 | 4 | |
| 5 | 5 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 6 | 6 | import { useStore } from "../store"; |
| ... | ... | @@ -27,7 +27,7 @@ interface BranchToolbarProps { |
| 27 | 27 | onEnvironmentChange?: (environmentId: EnvironmentId) => void; |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | export function BranchToolbar({ | |
| 30 | export const BranchToolbar = memo(function BranchToolbar({ | |
| 31 | 31 | environmentId, |
| 32 | 32 | threadId, |
| 33 | 33 | draftId, |
| ... | ... | @@ -101,4 +101,4 @@ export function BranchToolbar({ |
| 101 | 101 | /> |
| 102 | 102 | </div> |
| 103 | 103 | ); |
| 104 | } | |
| 104 | }); |
apps/web/src/components/ChatView.logic.ts+12| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | type EnvironmentId, |
| 3 | 3 | ProjectId, |
| 4 | 4 | type ModelSelection, |
| 5 | type ProviderKind, | |
| 5 | 6 | type ScopedThreadRef, |
| 6 | 7 | type ThreadId, |
| 7 | 8 | type TurnId, |
| ... | ... | @@ -225,6 +226,17 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { |
| 225 | 226 | ); |
| 226 | 227 | } |
| 227 | 228 | |
| 229 | export function deriveLockedProvider(input: { | |
| 230 | thread: Thread | null | undefined; | |
| 231 | selectedProvider: ProviderKind | null; | |
| 232 | threadProvider: ProviderKind | null; | |
| 233 | }): ProviderKind | null { | |
| 234 | if (!threadHasStarted(input.thread)) { | |
| 235 | return null; | |
| 236 | } | |
| 237 | return input.thread?.session?.provider ?? input.threadProvider ?? input.selectedProvider ?? null; | |
| 238 | } | |
| 239 | ||
| 228 | 240 | export async function waitForStartedServerThread( |
| 229 | 241 | threadRef: ScopedThreadRef, |
| 230 | 242 | timeoutMs = 1_000, |
apps/web/src/components/ChatView.tsx+200-1675| ... | ... | @@ -7,11 +7,8 @@ import { |
| 7 | 7 | type ModelSelection, |
| 8 | 8 | type ProjectScript, |
| 9 | 9 | type ProviderKind, |
| 10 | type ProjectEntry, | |
| 11 | 10 | type ProjectId, |
| 12 | 11 | type ProviderApprovalDecision, |
| 13 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 14 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | |
| 15 | 12 | type ServerProvider, |
| 16 | 13 | type ScopedThreadRef, |
| 17 | 14 | type ThreadId, |
| ... | ... | @@ -28,29 +25,21 @@ import { |
| 28 | 25 | scopeProjectRef, |
| 29 | 26 | scopeThreadRef, |
| 30 | 27 | } from "@t3tools/client-runtime"; |
| 31 | import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; | |
| 28 | import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; | |
| 32 | 29 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 33 | 30 | import { truncate } from "@t3tools/shared/String"; |
| 34 | 31 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 35 | import { useQuery } from "@tanstack/react-query"; | |
| 36 | import { useDebouncedValue } from "@tanstack/react-pacer"; | |
| 37 | 32 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 38 | 33 | import { useShallow } from "zustand/react/shallow"; |
| 39 | 34 | import { useGitStatus } from "~/lib/gitStatusState"; |
| 40 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | |
| 41 | 35 | import { usePrimaryEnvironmentId } from "../environments/primary"; |
| 42 | 36 | import { readEnvironmentApi } from "../environmentApi"; |
| 43 | 37 | import { isElectron } from "../env"; |
| 44 | 38 | import { readLocalApi } from "../localApi"; |
| 45 | 39 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 46 | 40 | import { |
| 47 | clampCollapsedComposerCursor, | |
| 48 | type ComposerTrigger, | |
| 49 | 41 | collapseExpandedComposerCursor, |
| 50 | detectComposerTrigger, | |
| 51 | expandCollapsedComposerCursor, | |
| 52 | 42 | parseStandaloneComposerSlashCommand, |
| 53 | replaceTextRange, | |
| 54 | 43 | } from "../composer-logic"; |
| 55 | 44 | import { |
| 56 | 45 | deriveCompletionDividerBeforeEntryId, |
| ... | ... | @@ -86,7 +75,6 @@ import { useUiStateStore } from "../uiStateStore"; |
| 86 | 75 | import { |
| 87 | 76 | buildPlanImplementationThreadTitle, |
| 88 | 77 | buildPlanImplementationPrompt, |
| 89 | proposedPlanTitle, | |
| 90 | 78 | resolvePlanFollowUpSubmission, |
| 91 | 79 | } from "../proposedPlan"; |
| 92 | 80 | import { |
| ... | ... | @@ -99,31 +87,14 @@ import { |
| 99 | 87 | type Thread, |
| 100 | 88 | type TurnDiffSummary, |
| 101 | 89 | } from "../types"; |
| 102 | import { basenameOfPath } from "../vscode-icons"; | |
| 103 | 90 | import { useTheme } from "../hooks/useTheme"; |
| 104 | 91 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 105 | 92 | import { BranchToolbar } from "./BranchToolbar"; |
| 106 | 93 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 107 | 94 | import PlanSidebar from "./PlanSidebar"; |
| 108 | 95 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 109 | import { | |
| 110 | BotIcon, | |
| 111 | ChevronDownIcon, | |
| 112 | ChevronLeftIcon, | |
| 113 | ChevronRightIcon, | |
| 114 | CircleAlertIcon, | |
| 115 | ListTodoIcon, | |
| 116 | LockIcon, | |
| 117 | LockOpenIcon, | |
| 118 | type LucideIcon, | |
| 119 | PenLineIcon, | |
| 120 | XIcon, | |
| 121 | } from "lucide-react"; | |
| 122 | import { Button } from "./ui/button"; | |
| 123 | import { Separator } from "./ui/separator"; | |
| 124 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | |
| 96 | import { ChevronDownIcon } from "lucide-react"; | |
| 125 | 97 | import { cn, randomUUID } from "~/lib/utils"; |
| 126 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | |
| 127 | 98 | import { toastManager } from "./ui/toast"; |
| 128 | 99 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 129 | 100 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| ... | ... | @@ -133,11 +104,7 @@ import { |
| 133 | 104 | projectScriptIdFromCommand, |
| 134 | 105 | } from "~/projectScripts"; |
| 135 | 106 | import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 136 | import { | |
| 137 | getProviderModelCapabilities, | |
| 138 | getProviderModels, | |
| 139 | resolveSelectableProvider, | |
| 140 | } from "../providerModels"; | |
| 107 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; | |
| 141 | 108 | import { useSettings } from "../hooks/useSettings"; |
| 142 | 109 | import { resolveAppModelSelection } from "../modelSelection"; |
| 143 | 110 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| ... | ... | @@ -150,49 +117,24 @@ import { buildDraftThreadRouteParams } from "../threadRoutes"; |
| 150 | 117 | import { |
| 151 | 118 | type ComposerImageAttachment, |
| 152 | 119 | type DraftThreadEnvMode, |
| 153 | type PersistedComposerImageAttachment, | |
| 154 | 120 | useComposerDraftStore, |
| 155 | useEffectiveComposerModelState, | |
| 156 | useComposerThreadDraft, | |
| 157 | 121 | type DraftId, |
| 158 | 122 | } from "../composerDraftStore"; |
| 159 | 123 | import { |
| 160 | 124 | appendTerminalContextsToPrompt, |
| 161 | 125 | formatTerminalContextLabel, |
| 162 | insertInlineTerminalContextPlaceholder, | |
| 163 | removeInlineTerminalContextPlaceholder, | |
| 164 | 126 | type TerminalContextDraft, |
| 165 | 127 | type TerminalContextSelection, |
| 166 | 128 | } from "../lib/terminalContext"; |
| 167 | import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; | |
| 168 | import { | |
| 169 | resolveComposerFooterContentWidth, | |
| 170 | shouldForceCompactComposerFooterForFit, | |
| 171 | shouldUseCompactComposerPrimaryActions, | |
| 172 | shouldUseCompactComposerFooter, | |
| 173 | } from "./composerFooterLayout"; | |
| 174 | 129 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 175 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | |
| 130 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; | |
| 131 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; | |
| 176 | 132 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 177 | 133 | import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 178 | 134 | import { ChatHeader } from "./chat/ChatHeader"; |
| 179 | import { ContextWindowMeter } from "./chat/ContextWindowMeter"; | |
| 180 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | |
| 135 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | |
| 181 | 136 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 182 | import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; | |
| 183 | import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; | |
| 184 | import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; | |
| 185 | import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; | |
| 186 | import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; | |
| 187 | import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; | |
| 188 | import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; | |
| 189 | import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; | |
| 190 | 137 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 191 | import { | |
| 192 | getComposerProviderState, | |
| 193 | renderProviderTraitsMenuContent, | |
| 194 | renderProviderTraitsPicker, | |
| 195 | } from "./chat/composerProviderRegistry"; | |
| 196 | 138 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; |
| 197 | 139 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 198 | 140 | import { |
| ... | ... | @@ -200,7 +142,6 @@ import { |
| 200 | 142 | buildExpiredTerminalContextToastCopy, |
| 201 | 143 | buildLocalDraftThread, |
| 202 | 144 | buildTemporaryWorktreeBranchName, |
| 203 | cloneComposerImageForRetry, | |
| 204 | 145 | collectUserMessageBlobPreviewUrls, |
| 205 | 146 | createLocalDispatchSnapshot, |
| 206 | 147 | deriveComposerSendState, |
| ... | ... | @@ -209,12 +150,13 @@ import { |
| 209 | 150 | LastInvokedScriptByProjectSchema, |
| 210 | 151 | type LocalDispatchSnapshot, |
| 211 | 152 | PullRequestDialogState, |
| 153 | cloneComposerImageForRetry, | |
| 154 | deriveLockedProvider, | |
| 212 | 155 | readFileAsDataUrl, |
| 213 | 156 | reconcileMountedTerminalThreadIds, |
| 214 | 157 | revokeBlobPreviewUrl, |
| 215 | 158 | revokeUserMessagePreviewUrls, |
| 216 | 159 | shouldWriteThreadErrorToCurrentServerThread, |
| 217 | threadHasStarted, | |
| 218 | 160 | waitForStartedServerThread, |
| 219 | 161 | } from "./ChatView.logic"; |
| 220 | 162 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| ... | ... | @@ -225,12 +167,10 @@ import { |
| 225 | 167 | } from "~/rpc/serverState"; |
| 226 | 168 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; |
| 227 | 169 | |
| 228 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; | |
| 229 | 170 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 230 | 171 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 231 | 172 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 232 | 173 | const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; |
| 233 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; | |
| 234 | 174 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 235 | 175 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 236 | 176 | |
| ... | ... | @@ -362,38 +302,9 @@ function formatOutgoingPrompt(params: { |
| 362 | 302 | } |
| 363 | 303 | return params.text; |
| 364 | 304 | } |
| 365 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; | |
| 366 | 305 | const SCRIPT_TERMINAL_COLS = 120; |
| 367 | 306 | const SCRIPT_TERMINAL_ROWS = 30; |
| 368 | 307 | |
| 369 | const extendReplacementRangeForTrailingSpace = ( | |
| 370 | text: string, | |
| 371 | rangeEnd: number, | |
| 372 | replacement: string, | |
| 373 | ): number => { | |
| 374 | if (!replacement.endsWith(" ")) { | |
| 375 | return rangeEnd; | |
| 376 | } | |
| 377 | return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; | |
| 378 | }; | |
| 379 | ||
| 380 | const syncTerminalContextsByIds = ( | |
| 381 | contexts: ReadonlyArray<TerminalContextDraft>, | |
| 382 | ids: ReadonlyArray<string>, | |
| 383 | ): TerminalContextDraft[] => { | |
| 384 | const contextsById = new Map(contexts.map((context) => [context.id, context])); | |
| 385 | return ids.flatMap((id) => { | |
| 386 | const context = contextsById.get(id); | |
| 387 | return context ? [context] : []; | |
| 388 | }); | |
| 389 | }; | |
| 390 | ||
| 391 | const terminalContextIdListsEqual = ( | |
| 392 | contexts: ReadonlyArray<TerminalContextDraft>, | |
| 393 | ids: ReadonlyArray<string>, | |
| 394 | ): boolean => | |
| 395 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); | |
| 396 | ||
| 397 | 308 | type ChatViewProps = |
| 398 | 309 | | { |
| 399 | 310 | environmentId: EnvironmentId; |
| ... | ... | @@ -414,29 +325,6 @@ interface TerminalLaunchContext { |
| 414 | 325 | worktreePath: string | null; |
| 415 | 326 | } |
| 416 | 327 | |
| 417 | const runtimeModeConfig: Record< | |
| 418 | RuntimeMode, | |
| 419 | { label: string; description: string; icon: LucideIcon } | |
| 420 | > = { | |
| 421 | "approval-required": { | |
| 422 | label: "Supervised", | |
| 423 | description: "Ask before commands and file changes.", | |
| 424 | icon: LockIcon, | |
| 425 | }, | |
| 426 | "auto-accept-edits": { | |
| 427 | label: "Auto-accept edits", | |
| 428 | description: "Auto-approve edits, ask before other actions.", | |
| 429 | icon: PenLineIcon, | |
| 430 | }, | |
| 431 | "full-access": { | |
| 432 | label: "Full access", | |
| 433 | description: "Allow commands and edits without prompts.", | |
| 434 | icon: LockOpenIcon, | |
| 435 | }, | |
| 436 | }; | |
| 437 | ||
| 438 | const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; | |
| 439 | ||
| 440 | 328 | type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "worktreePath">; |
| 441 | 329 | |
| 442 | 330 | function useLocalDispatchState(input: { |
| ... | ... | @@ -712,53 +600,31 @@ export default function ChatView(props: ChatViewProps) { |
| 712 | 600 | select: (params) => parseDiffRouteSearch(params), |
| 713 | 601 | }); |
| 714 | 602 | const { resolvedTheme } = useTheme(); |
| 715 | const composerDraft = useComposerThreadDraft(composerDraftTarget); | |
| 716 | const prompt = composerDraft.prompt; | |
| 717 | const composerImages = composerDraft.images; | |
| 718 | const composerTerminalContexts = composerDraft.terminalContexts; | |
| 719 | const composerSendState = useMemo( | |
| 720 | () => | |
| 721 | deriveComposerSendState({ | |
| 722 | prompt, | |
| 723 | imageCount: composerImages.length, | |
| 724 | terminalContexts: composerTerminalContexts, | |
| 725 | }), | |
| 726 | [composerImages.length, composerTerminalContexts, prompt], | |
| 727 | ); | |
| 728 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; | |
| 729 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); | |
| 730 | const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); | |
| 731 | const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); | |
| 732 | const setComposerDraftInteractionMode = useComposerDraftStore( | |
| 733 | (store) => store.setInteractionMode, | |
| 734 | ); | |
| 735 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); | |
| 736 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); | |
| 737 | const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); | |
| 738 | const insertComposerDraftTerminalContext = useComposerDraftStore( | |
| 739 | (store) => store.insertTerminalContext, | |
| 603 | // Granular store selectors — avoid subscribing to prompt changes. | |
| 604 | const composerRuntimeMode = useComposerDraftStore( | |
| 605 | (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null, | |
| 740 | 606 | ); |
| 741 | const addComposerDraftTerminalContexts = useComposerDraftStore( | |
| 742 | (store) => store.addTerminalContexts, | |
| 607 | const composerInteractionMode = useComposerDraftStore( | |
| 608 | (store) => store.getComposerDraft(composerDraftTarget)?.interactionMode ?? null, | |
| 743 | 609 | ); |
| 744 | const removeComposerDraftTerminalContext = useComposerDraftStore( | |
| 745 | (store) => store.removeTerminalContext, | |
| 610 | const composerActiveProvider = useComposerDraftStore( | |
| 611 | (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, | |
| 746 | 612 | ); |
| 613 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); | |
| 614 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); | |
| 747 | 615 | const setComposerDraftTerminalContexts = useComposerDraftStore( |
| 748 | 616 | (store) => store.setTerminalContexts, |
| 749 | 617 | ); |
| 750 | const clearComposerDraftPersistedAttachments = useComposerDraftStore( | |
| 751 | (store) => store.clearPersistedAttachments, | |
| 752 | ); | |
| 753 | const syncComposerDraftPersistedAttachments = useComposerDraftStore( | |
| 754 | (store) => store.syncPersistedAttachments, | |
| 618 | const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); | |
| 619 | const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); | |
| 620 | const setComposerDraftInteractionMode = useComposerDraftStore( | |
| 621 | (store) => store.setInteractionMode, | |
| 755 | 622 | ); |
| 756 | 623 | const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); |
| 757 | 624 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 758 | 625 | const getDraftSessionByLogicalProjectKey = useComposerDraftStore( |
| 759 | 626 | (store) => store.getDraftSessionByLogicalProjectKey, |
| 760 | 627 | ); |
| 761 | const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); | |
| 762 | 628 | const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); |
| 763 | 629 | const setLogicalProjectDraftThreadId = useComposerDraftStore( |
| 764 | 630 | (store) => store.setLogicalProjectDraftThreadId, |
| ... | ... | @@ -770,14 +636,15 @@ export default function ChatView(props: ChatViewProps) { |
| 770 | 636 | ? store.getDraftSession(draftId) |
| 771 | 637 | : null, |
| 772 | 638 | ); |
| 773 | const promptRef = useRef(prompt); | |
| 639 | const promptRef = useRef(""); | |
| 640 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); | |
| 641 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]); | |
| 642 | const composerRef = useRef<ChatComposerHandle>(null); | |
| 774 | 643 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); |
| 775 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); | |
| 776 | 644 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 777 | 645 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| 778 | 646 | const optimisticUserMessagesRef = useRef(optimisticUserMessages); |
| 779 | 647 | optimisticUserMessagesRef.current = optimisticUserMessages; |
| 780 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts); | |
| 781 | 648 | const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< |
| 782 | 649 | Record<string, string | null> |
| 783 | 650 | >({}); |
| ... | ... | @@ -794,8 +661,6 @@ export default function ChatView(props: ChatViewProps) { |
| 794 | 661 | useState<Record<string, number>>({}); |
| 795 | 662 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 796 | 663 | const [planSidebarOpen, setPlanSidebarOpen] = useState(false); |
| 797 | const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); | |
| 798 | const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); | |
| 799 | 664 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. |
| 800 | 665 | const planSidebarDismissedForTurnRef = useRef<string | null>(null); |
| 801 | 666 | // 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) { |
| 803 | 668 | const planSidebarOpenOnNextThreadRef = useRef(false); |
| 804 | 669 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 805 | 670 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 806 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | |
| 807 | 671 | const [pullRequestDialogState, setPullRequestDialogState] = |
| 808 | 672 | useState<PullRequestDialogState | null>(null); |
| 809 | 673 | const [terminalLaunchContext, setTerminalLaunchContext] = useState<TerminalLaunchContext | null>( |
| ... | ... | @@ -812,12 +676,6 @@ export default function ChatView(props: ChatViewProps) { |
| 812 | 676 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 813 | 677 | Record<string, string[]> |
| 814 | 678 | >({}); |
| 815 | const [composerCursor, setComposerCursor] = useState(() => | |
| 816 | collapseExpandedComposerCursor(prompt, prompt.length), | |
| 817 | ); | |
| 818 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => | |
| 819 | detectComposerTrigger(prompt, prompt.length), | |
| 820 | ); | |
| 821 | 679 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( |
| 822 | 680 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 823 | 681 | {}, |
| ... | ... | @@ -836,21 +694,9 @@ export default function ChatView(props: ChatViewProps) { |
| 836 | 694 | top: number; |
| 837 | 695 | } | null>(null); |
| 838 | 696 | const pendingInteractionAnchorFrameRef = useRef<number | null>(null); |
| 839 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); | |
| 840 | const composerFormRef = useRef<HTMLFormElement>(null); | |
| 841 | const composerFormHeightRef = useRef(0); | |
| 842 | const composerFooterRef = useRef<HTMLDivElement>(null); | |
| 843 | const composerFooterLeadingRef = useRef<HTMLDivElement>(null); | |
| 844 | const composerFooterActionsRef = useRef<HTMLDivElement>(null); | |
| 845 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); | |
| 846 | const composerSelectLockRef = useRef(false); | |
| 847 | const composerMenuOpenRef = useRef(false); | |
| 848 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); | |
| 849 | const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null); | |
| 850 | 697 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 851 | 698 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); |
| 852 | 699 | const sendInFlightRef = useRef(false); |
| 853 | const dragDepthRef = useRef(0); | |
| 854 | 700 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 855 | 701 | const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { |
| 856 | 702 | messagesScrollRef.current = element; |
| ... | ... | @@ -903,59 +749,6 @@ export default function ChatView(props: ChatViewProps) { |
| 903 | 749 | [mountedTerminalThreadKeys], |
| 904 | 750 | ); |
| 905 | 751 | |
| 906 | const setPrompt = useCallback( | |
| 907 | (nextPrompt: string) => { | |
| 908 | setComposerDraftPrompt(composerDraftTarget, nextPrompt); | |
| 909 | }, | |
| 910 | [composerDraftTarget, setComposerDraftPrompt], | |
| 911 | ); | |
| 912 | const addComposerImage = useCallback( | |
| 913 | (image: ComposerImageAttachment) => { | |
| 914 | addComposerDraftImage(composerDraftTarget, image); | |
| 915 | }, | |
| 916 | [addComposerDraftImage, composerDraftTarget], | |
| 917 | ); | |
| 918 | const addComposerImagesToDraft = useCallback( | |
| 919 | (images: ComposerImageAttachment[]) => { | |
| 920 | addComposerDraftImages(composerDraftTarget, images); | |
| 921 | }, | |
| 922 | [addComposerDraftImages, composerDraftTarget], | |
| 923 | ); | |
| 924 | const addComposerTerminalContextsToDraft = useCallback( | |
| 925 | (contexts: TerminalContextDraft[]) => { | |
| 926 | addComposerDraftTerminalContexts(composerDraftTarget, contexts); | |
| 927 | }, | |
| 928 | [addComposerDraftTerminalContexts, composerDraftTarget], | |
| 929 | ); | |
| 930 | const removeComposerImageFromDraft = useCallback( | |
| 931 | (imageId: string) => { | |
| 932 | removeComposerDraftImage(composerDraftTarget, imageId); | |
| 933 | }, | |
| 934 | [composerDraftTarget, removeComposerDraftImage], | |
| 935 | ); | |
| 936 | const removeComposerTerminalContextFromDraft = useCallback( | |
| 937 | (contextId: string) => { | |
| 938 | const contextIndex = composerTerminalContexts.findIndex( | |
| 939 | (context) => context.id === contextId, | |
| 940 | ); | |
| 941 | if (contextIndex < 0) { | |
| 942 | return; | |
| 943 | } | |
| 944 | const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); | |
| 945 | promptRef.current = nextPrompt.prompt; | |
| 946 | setPrompt(nextPrompt.prompt); | |
| 947 | removeComposerDraftTerminalContext(composerDraftTarget, contextId); | |
| 948 | setComposerCursor(nextPrompt.cursor); | |
| 949 | setComposerTrigger( | |
| 950 | detectComposerTrigger( | |
| 951 | nextPrompt.prompt, | |
| 952 | expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor), | |
| 953 | ), | |
| 954 | ); | |
| 955 | }, | |
| 956 | [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt], | |
| 957 | ); | |
| 958 | ||
| 959 | 752 | const fallbackDraftProjectRef = draftThread |
| 960 | 753 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| 961 | 754 | : null; |
| ... | ... | @@ -983,12 +776,9 @@ export default function ChatView(props: ChatViewProps) { |
| 983 | 776 | ); |
| 984 | 777 | const isServerThread = routeKind === "server" && serverThread !== undefined; |
| 985 | 778 | const activeThread = isServerThread ? serverThread : localDraftThread; |
| 986 | const runtimeMode = | |
| 987 | composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; | |
| 779 | const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; | |
| 988 | 780 | const interactionMode = |
| 989 | composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; | |
| 990 | const runtimeModeOption = runtimeModeConfig[runtimeMode]; | |
| 991 | const RuntimeModeIcon = runtimeModeOption.icon; | |
| 781 | composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; | |
| 992 | 782 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 993 | 783 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 994 | 784 | const diffOpen = rawSearch.diff === "1"; |
| ... | ... | @@ -1016,10 +806,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1016 | 806 | return threadIds; |
| 1017 | 807 | }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), |
| 1018 | 808 | ); |
| 1019 | const activeContextWindow = useMemo( | |
| 1020 | () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []), | |
| 1021 | [activeThread?.activities], | |
| 1022 | ); | |
| 1023 | 809 | useEffect(() => { |
| 1024 | 810 | setMountedTerminalThreadKeys((currentThreadIds) => { |
| 1025 | 811 | const nextThreadIds = reconcileMountedTerminalThreadIds({ |
| ... | ... | @@ -1103,7 +889,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1103 | 889 | initialReference: reference ?? null, |
| 1104 | 890 | key: Date.now(), |
| 1105 | 891 | }); |
| 1106 | setComposerHighlightedItemId(null); | |
| 1107 | 892 | }, |
| 1108 | 893 | [canCheckoutPullRequestIntoThread], |
| 1109 | 894 | ); |
| ... | ... | @@ -1215,14 +1000,14 @@ export default function ChatView(props: ChatViewProps) { |
| 1215 | 1000 | serverThread?.id, |
| 1216 | 1001 | ]); |
| 1217 | 1002 | |
| 1218 | const sessionProvider = activeThread?.session?.provider ?? null; | |
| 1219 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; | |
| 1003 | const selectedProviderByThreadId = composerActiveProvider ?? null; | |
| 1220 | 1004 | const threadProvider = |
| 1221 | 1005 | activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; |
| 1222 | const hasThreadStarted = threadHasStarted(activeThread); | |
| 1223 | const lockedProvider: ProviderKind | null = hasThreadStarted | |
| 1224 | ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) | |
| 1225 | : null; | |
| 1006 | const lockedProvider = deriveLockedProvider({ | |
| 1007 | thread: activeThread, | |
| 1008 | selectedProvider: selectedProviderByThreadId, | |
| 1009 | threadProvider, | |
| 1010 | }); | |
| 1226 | 1011 | const primaryServerConfig = useServerConfig(); |
| 1227 | 1012 | const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => |
| 1228 | 1013 | activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, |
| ... | ... | @@ -1240,37 +1025,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1240 | 1025 | selectedProviderByThreadId ?? threadProvider ?? "codex", |
| 1241 | 1026 | ); |
| 1242 | 1027 | const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; |
| 1243 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ | |
| 1244 | threadRef: composerDraftTarget, | |
| 1245 | providers: providerStatuses, | |
| 1246 | selectedProvider, | |
| 1247 | threadModelSelection: activeThread?.modelSelection, | |
| 1248 | projectModelSelection: activeProject?.defaultModelSelection, | |
| 1249 | settings, | |
| 1250 | }); | |
| 1251 | const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); | |
| 1252 | const composerProviderState = useMemo( | |
| 1253 | () => | |
| 1254 | getComposerProviderState({ | |
| 1255 | provider: selectedProvider, | |
| 1256 | model: selectedModel, | |
| 1257 | models: selectedProviderModels, | |
| 1258 | prompt, | |
| 1259 | modelOptions: composerModelOptions, | |
| 1260 | }), | |
| 1261 | [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], | |
| 1262 | ); | |
| 1263 | const selectedPromptEffort = composerProviderState.promptEffort; | |
| 1264 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; | |
| 1265 | const selectedModelSelection = useMemo<ModelSelection>( | |
| 1266 | () => ({ | |
| 1267 | provider: selectedProvider, | |
| 1268 | model: selectedModel, | |
| 1269 | ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), | |
| 1270 | }), | |
| 1271 | [selectedModel, selectedModelOptionsForDispatch, selectedProvider], | |
| 1272 | ); | |
| 1273 | const selectedModelForPicker = selectedModel; | |
| 1274 | 1028 | const phase = derivePhase(activeThread?.session ?? null); |
| 1275 | 1029 | const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; |
| 1276 | 1030 | const workLogEntries = useMemo( |
| ... | ... | @@ -1372,75 +1126,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1372 | 1126 | activeThread?.session ?? null, |
| 1373 | 1127 | localDispatchStartedAt, |
| 1374 | 1128 | ); |
| 1375 | const isComposerApprovalState = activePendingApproval !== null; | |
| 1376 | const hasComposerHeader = | |
| 1377 | isComposerApprovalState || | |
| 1378 | pendingUserInputs.length > 0 || | |
| 1379 | (showPlanFollowUpPrompt && activeProposedPlan !== null); | |
| 1380 | const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; | |
| 1381 | const composerFooterActionLayoutKey = useMemo(() => { | |
| 1382 | if (activePendingProgress) { | |
| 1383 | return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; | |
| 1384 | } | |
| 1385 | if (phase === "running") { | |
| 1386 | return "running"; | |
| 1387 | } | |
| 1388 | if (showPlanFollowUpPrompt) { | |
| 1389 | return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; | |
| 1390 | } | |
| 1391 | return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; | |
| 1392 | }, [ | |
| 1393 | activePendingIsResponding, | |
| 1394 | activePendingProgress, | |
| 1395 | composerSendState.hasSendableContent, | |
| 1396 | isConnecting, | |
| 1397 | isPreparingWorktree, | |
| 1398 | isSendBusy, | |
| 1399 | phase, | |
| 1400 | prompt, | |
| 1401 | showPlanFollowUpPrompt, | |
| 1402 | ]); | |
| 1403 | const lastSyncedPendingInputRef = useRef<{ | |
| 1404 | requestId: string | null; | |
| 1405 | questionId: string | null; | |
| 1406 | } | null>(null); | |
| 1407 | useEffect(() => { | |
| 1408 | const nextCustomAnswer = activePendingProgress?.customAnswer; | |
| 1409 | if (typeof nextCustomAnswer !== "string") { | |
| 1410 | lastSyncedPendingInputRef.current = null; | |
| 1411 | return; | |
| 1412 | } | |
| 1413 | const nextRequestId = activePendingUserInput?.requestId ?? null; | |
| 1414 | const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; | |
| 1415 | const questionChanged = | |
| 1416 | lastSyncedPendingInputRef.current?.requestId !== nextRequestId || | |
| 1417 | lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; | |
| 1418 | const textChangedExternally = promptRef.current !== nextCustomAnswer; | |
| 1419 | ||
| 1420 | lastSyncedPendingInputRef.current = { | |
| 1421 | requestId: nextRequestId, | |
| 1422 | questionId: nextQuestionId, | |
| 1423 | }; | |
| 1424 | ||
| 1425 | if (!questionChanged && !textChangedExternally) { | |
| 1426 | return; | |
| 1427 | } | |
| 1428 | ||
| 1429 | promptRef.current = nextCustomAnswer; | |
| 1430 | const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); | |
| 1431 | setComposerCursor(nextCursor); | |
| 1432 | setComposerTrigger( | |
| 1433 | detectComposerTrigger( | |
| 1434 | nextCustomAnswer, | |
| 1435 | expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), | |
| 1436 | ), | |
| 1437 | ); | |
| 1438 | setComposerHighlightedItemId(null); | |
| 1439 | }, [ | |
| 1440 | activePendingProgress?.customAnswer, | |
| 1441 | activePendingUserInput?.requestId, | |
| 1442 | activePendingProgress?.activeQuestion?.id, | |
| 1443 | ]); | |
| 1444 | 1129 | useEffect(() => { |
| 1445 | 1130 | attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; |
| 1446 | 1131 | }, [attachmentPreviewHandoffByMessageId]); |
| ... | ... | @@ -1708,139 +1393,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1708 | 1393 | worktreePath: activeThread?.worktreePath ?? null, |
| 1709 | 1394 | }) |
| 1710 | 1395 | : null; |
| 1711 | const composerTriggerKind = composerTrigger?.kind ?? null; | |
| 1712 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; | |
| 1713 | const isPathTrigger = composerTriggerKind === "path"; | |
| 1714 | const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( | |
| 1715 | pathTriggerQuery, | |
| 1716 | { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, | |
| 1717 | (debouncerState) => ({ isPending: debouncerState.isPending }), | |
| 1718 | ); | |
| 1719 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; | |
| 1720 | 1396 | const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); |
| 1721 | 1397 | const keybindings = useServerKeybindings(); |
| 1722 | 1398 | const availableEditors = useServerAvailableEditors(); |
| 1723 | const modelOptionsByProvider = useMemo< | |
| 1724 | Record<ProviderKind, ReadonlyArray<ServerProvider["models"][number]>> | |
| 1725 | >( | |
| 1726 | () => ({ | |
| 1727 | codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], | |
| 1728 | claudeAgent: | |
| 1729 | providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], | |
| 1730 | }), | |
| 1731 | [providerStatuses], | |
| 1732 | ); | |
| 1733 | const selectedModelForPickerWithCustomFallback = useMemo(() => { | |
| 1734 | const currentOptions = modelOptionsByProvider[selectedProvider]; | |
| 1735 | return currentOptions.some((option) => option.slug === selectedModelForPicker) | |
| 1736 | ? selectedModelForPicker | |
| 1737 | : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); | |
| 1738 | }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); | |
| 1739 | const searchableModelOptions = useMemo( | |
| 1740 | () => | |
| 1741 | AVAILABLE_PROVIDER_OPTIONS.filter( | |
| 1742 | (option) => lockedProvider === null || option.value === lockedProvider, | |
| 1743 | ).flatMap((option) => | |
| 1744 | modelOptionsByProvider[option.value].map(({ slug, name }) => ({ | |
| 1745 | provider: option.value, | |
| 1746 | providerLabel: option.label, | |
| 1747 | slug, | |
| 1748 | name, | |
| 1749 | searchSlug: slug.toLowerCase(), | |
| 1750 | searchName: name.toLowerCase(), | |
| 1751 | searchProvider: option.label.toLowerCase(), | |
| 1752 | })), | |
| 1753 | ), | |
| 1754 | [lockedProvider, modelOptionsByProvider], | |
| 1755 | ); | |
| 1756 | const workspaceEntriesQuery = useQuery( | |
| 1757 | projectSearchEntriesQueryOptions({ | |
| 1758 | environmentId, | |
| 1759 | cwd: gitCwd, | |
| 1760 | query: effectivePathQuery, | |
| 1761 | enabled: isPathTrigger, | |
| 1762 | limit: 80, | |
| 1763 | }), | |
| 1764 | ); | |
| 1765 | const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; | |
| 1766 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { | |
| 1767 | if (!composerTrigger) return []; | |
| 1768 | if (composerTrigger.kind === "path") { | |
| 1769 | return workspaceEntries.map((entry) => ({ | |
| 1770 | id: `path:${entry.kind}:${entry.path}`, | |
| 1771 | type: "path", | |
| 1772 | path: entry.path, | |
| 1773 | pathKind: entry.kind, | |
| 1774 | label: basenameOfPath(entry.path), | |
| 1775 | description: entry.parentPath ?? "", | |
| 1776 | })); | |
| 1777 | } | |
| 1778 | ||
| 1779 | if (composerTrigger.kind === "slash-command") { | |
| 1780 | const slashCommandItems = [ | |
| 1781 | { | |
| 1782 | id: "slash:model", | |
| 1783 | type: "slash-command", | |
| 1784 | command: "model", | |
| 1785 | label: "/model", | |
| 1786 | description: "Switch response model for this thread", | |
| 1787 | }, | |
| 1788 | { | |
| 1789 | id: "slash:plan", | |
| 1790 | type: "slash-command", | |
| 1791 | command: "plan", | |
| 1792 | label: "/plan", | |
| 1793 | description: "Switch this thread into plan mode", | |
| 1794 | }, | |
| 1795 | { | |
| 1796 | id: "slash:default", | |
| 1797 | type: "slash-command", | |
| 1798 | command: "default", | |
| 1799 | label: "/default", | |
| 1800 | description: "Switch this thread back to normal build mode", | |
| 1801 | }, | |
| 1802 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; | |
| 1803 | const query = composerTrigger.query.trim().toLowerCase(); | |
| 1804 | if (!query) { | |
| 1805 | return [...slashCommandItems]; | |
| 1806 | } | |
| 1807 | return slashCommandItems.filter( | |
| 1808 | (item) => item.command.includes(query) || item.label.slice(1).includes(query), | |
| 1809 | ); | |
| 1810 | } | |
| 1811 | ||
| 1812 | return searchableModelOptions | |
| 1813 | .filter(({ searchSlug, searchName, searchProvider }) => { | |
| 1814 | const query = composerTrigger.query.trim().toLowerCase(); | |
| 1815 | if (!query) return true; | |
| 1816 | return ( | |
| 1817 | searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) | |
| 1818 | ); | |
| 1819 | }) | |
| 1820 | .map(({ provider, providerLabel, slug, name }) => ({ | |
| 1821 | id: `model:${provider}:${slug}`, | |
| 1822 | type: "model", | |
| 1823 | provider, | |
| 1824 | model: slug, | |
| 1825 | label: name, | |
| 1826 | description: `${providerLabel} · ${slug}`, | |
| 1827 | })); | |
| 1828 | }, [composerTrigger, searchableModelOptions, workspaceEntries]); | |
| 1829 | const composerMenuOpen = Boolean(composerTrigger); | |
| 1830 | const activeComposerMenuItem = useMemo( | |
| 1831 | () => | |
| 1832 | composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? | |
| 1833 | composerMenuItems[0] ?? | |
| 1834 | null, | |
| 1835 | [composerHighlightedItemId, composerMenuItems], | |
| 1836 | ); | |
| 1837 | composerMenuOpenRef.current = composerMenuOpen; | |
| 1838 | composerMenuItemsRef.current = composerMenuItems; | |
| 1839 | activeComposerMenuItemRef.current = activeComposerMenuItem; | |
| 1840 | const nonPersistedComposerImageIdSet = useMemo( | |
| 1841 | () => new Set(nonPersistedComposerImageIds), | |
| 1842 | [nonPersistedComposerImageIds], | |
| 1843 | ); | |
| 1844 | 1399 | const activeProviderStatus = useMemo( |
| 1845 | 1400 | () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, |
| 1846 | 1401 | [selectedProvider, providerStatuses], |
| ... | ... | @@ -1933,29 +1488,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1933 | 1488 | [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], |
| 1934 | 1489 | ); |
| 1935 | 1490 | |
| 1936 | // Auto-correct the draft model selection when the environment changes and | |
| 1937 | // the previously-selected provider/model is no longer available. This keeps | |
| 1938 | // the stored draft state consistent with the resolved picker values and | |
| 1939 | // prevents stale model references when the user sends a message. | |
| 1940 | const prevEnvironmentIdRef = useRef(activeThread?.environmentId); | |
| 1941 | useEffect(() => { | |
| 1942 | const currentEnvId = activeThread?.environmentId; | |
| 1943 | if (!currentEnvId || envLocked || prevEnvironmentIdRef.current === currentEnvId) { | |
| 1944 | prevEnvironmentIdRef.current = currentEnvId; | |
| 1945 | return; | |
| 1946 | } | |
| 1947 | prevEnvironmentIdRef.current = currentEnvId; | |
| 1948 | ||
| 1949 | // The resolved provider/model already account for the new environment's | |
| 1950 | // provider list. Persist that resolved selection into the draft. | |
| 1951 | if (activeThread) { | |
| 1952 | setComposerDraftModelSelection(scopeThreadRef(activeThread.environmentId, activeThread.id), { | |
| 1953 | provider: selectedProvider, | |
| 1954 | model: selectedModel, | |
| 1955 | }); | |
| 1956 | } | |
| 1957 | }, [activeThread, envLocked, selectedModel, selectedProvider, setComposerDraftModelSelection]); | |
| 1958 | ||
| 1959 | 1491 | const activeTerminalGroup = |
| 1960 | 1492 | terminalState.terminalGroups.find( |
| 1961 | 1493 | (group) => group.id === terminalState.activeTerminalGroupId, |
| ... | ... | @@ -1994,55 +1526,16 @@ export default function ChatView(props: ChatViewProps) { |
| 1994 | 1526 | ); |
| 1995 | 1527 | |
| 1996 | 1528 | const focusComposer = useCallback(() => { |
| 1997 | composerEditorRef.current?.focusAtEnd(); | |
| 1529 | composerRef.current?.focusAtEnd(); | |
| 1998 | 1530 | }, []); |
| 1999 | 1531 | const scheduleComposerFocus = useCallback(() => { |
| 2000 | 1532 | window.requestAnimationFrame(() => { |
| 2001 | 1533 | focusComposer(); |
| 2002 | 1534 | }); |
| 2003 | 1535 | }, [focusComposer]); |
| 2004 | const addTerminalContextToDraft = useCallback( | |
| 2005 | (selection: TerminalContextSelection) => { | |
| 2006 | if (!activeThread) { | |
| 2007 | return; | |
| 2008 | } | |
| 2009 | const snapshot = composerEditorRef.current?.readSnapshot() ?? { | |
| 2010 | value: promptRef.current, | |
| 2011 | cursor: composerCursor, | |
| 2012 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), | |
| 2013 | terminalContextIds: composerTerminalContexts.map((context) => context.id), | |
| 2014 | }; | |
| 2015 | const insertion = insertInlineTerminalContextPlaceholder( | |
| 2016 | snapshot.value, | |
| 2017 | snapshot.expandedCursor, | |
| 2018 | ); | |
| 2019 | const nextCollapsedCursor = collapseExpandedComposerCursor( | |
| 2020 | insertion.prompt, | |
| 2021 | insertion.cursor, | |
| 2022 | ); | |
| 2023 | const inserted = insertComposerDraftTerminalContext( | |
| 2024 | scopeThreadRef(activeThread.environmentId, activeThread.id), | |
| 2025 | insertion.prompt, | |
| 2026 | { | |
| 2027 | id: randomUUID(), | |
| 2028 | threadId: activeThread.id, | |
| 2029 | createdAt: new Date().toISOString(), | |
| 2030 | ...selection, | |
| 2031 | }, | |
| 2032 | insertion.contextIndex, | |
| 2033 | ); | |
| 2034 | if (!inserted) { | |
| 2035 | return; | |
| 2036 | } | |
| 2037 | promptRef.current = insertion.prompt; | |
| 2038 | setComposerCursor(nextCollapsedCursor); | |
| 2039 | setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); | |
| 2040 | window.requestAnimationFrame(() => { | |
| 2041 | composerEditorRef.current?.focusAt(nextCollapsedCursor); | |
| 2042 | }); | |
| 2043 | }, | |
| 2044 | [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext], | |
| 2045 | ); | |
| 1536 | const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => { | |
| 1537 | composerRef.current?.addTerminalContext(selection); | |
| 1538 | }, []); | |
| 2046 | 1539 | const setTerminalOpen = useCallback( |
| 2047 | 1540 | (open: boolean) => { |
| 2048 | 1541 | if (!activeThreadRef) return; |
| ... | ... | @@ -2600,80 +2093,6 @@ export default function ChatView(props: ChatViewProps) { |
| 2600 | 2093 | window.clearTimeout(timeout); |
| 2601 | 2094 | }; |
| 2602 | 2095 | }, [activeThread?.id, scheduleStickToBottom]); |
| 2603 | useLayoutEffect(() => { | |
| 2604 | const composerForm = composerFormRef.current; | |
| 2605 | if (!composerForm) return; | |
| 2606 | const measureComposerFormWidth = () => composerForm.clientWidth; | |
| 2607 | const measureFooterCompactness = () => { | |
| 2608 | const composerFormWidth = measureComposerFormWidth(); | |
| 2609 | const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, { | |
| 2610 | hasWideActions: composerFooterHasWideActions, | |
| 2611 | }); | |
| 2612 | const footer = composerFooterRef.current; | |
| 2613 | const footerStyle = footer ? window.getComputedStyle(footer) : null; | |
| 2614 | const footerContentWidth = resolveComposerFooterContentWidth({ | |
| 2615 | footerWidth: footer?.clientWidth ?? null, | |
| 2616 | paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null, | |
| 2617 | paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null, | |
| 2618 | }); | |
| 2619 | const fitInput = { | |
| 2620 | footerContentWidth, | |
| 2621 | leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null, | |
| 2622 | actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null, | |
| 2623 | }; | |
| 2624 | const nextFooterCompact = | |
| 2625 | heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput); | |
| 2626 | const nextPrimaryActionsCompact = | |
| 2627 | nextFooterCompact && | |
| 2628 | shouldUseCompactComposerPrimaryActions(composerFormWidth, { | |
| 2629 | hasWideActions: composerFooterHasWideActions, | |
| 2630 | }); | |
| 2631 | ||
| 2632 | return { | |
| 2633 | primaryActionsCompact: nextPrimaryActionsCompact, | |
| 2634 | footerCompact: nextFooterCompact, | |
| 2635 | }; | |
| 2636 | }; | |
| 2637 | ||
| 2638 | composerFormHeightRef.current = composerForm.getBoundingClientRect().height; | |
| 2639 | const initialCompactness = measureFooterCompactness(); | |
| 2640 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); | |
| 2641 | setIsComposerFooterCompact(initialCompactness.footerCompact); | |
| 2642 | if (typeof ResizeObserver === "undefined") return; | |
| 2643 | ||
| 2644 | const observer = new ResizeObserver((entries) => { | |
| 2645 | const [entry] = entries; | |
| 2646 | if (!entry) return; | |
| 2647 | ||
| 2648 | const nextCompactness = measureFooterCompactness(); | |
| 2649 | setIsComposerPrimaryActionsCompact((previous) => | |
| 2650 | previous === nextCompactness.primaryActionsCompact | |
| 2651 | ? previous | |
| 2652 | : nextCompactness.primaryActionsCompact, | |
| 2653 | ); | |
| 2654 | setIsComposerFooterCompact((previous) => | |
| 2655 | previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, | |
| 2656 | ); | |
| 2657 | ||
| 2658 | const nextHeight = entry.contentRect.height; | |
| 2659 | const previousHeight = composerFormHeightRef.current; | |
| 2660 | composerFormHeightRef.current = nextHeight; | |
| 2661 | ||
| 2662 | if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; | |
| 2663 | if (!shouldAutoScrollRef.current) return; | |
| 2664 | scheduleStickToBottom(); | |
| 2665 | }); | |
| 2666 | ||
| 2667 | observer.observe(composerForm); | |
| 2668 | return () => { | |
| 2669 | observer.disconnect(); | |
| 2670 | }; | |
| 2671 | }, [ | |
| 2672 | activeThread?.id, | |
| 2673 | composerFooterActionLayoutKey, | |
| 2674 | composerFooterHasWideActions, | |
| 2675 | scheduleStickToBottom, | |
| 2676 | ]); | |
| 2677 | 2096 | useEffect(() => { |
| 2678 | 2097 | if (!shouldAutoScrollRef.current) return; |
| 2679 | 2098 | scheduleStickToBottom(); |
| ... | ... | @@ -2696,18 +2115,6 @@ export default function ChatView(props: ChatViewProps) { |
| 2696 | 2115 | planSidebarDismissedForTurnRef.current = null; |
| 2697 | 2116 | }, [activeThread?.id]); |
| 2698 | 2117 | |
| 2699 | useEffect(() => { | |
| 2700 | if (!composerMenuOpen) { | |
| 2701 | setComposerHighlightedItemId(null); | |
| 2702 | return; | |
| 2703 | } | |
| 2704 | setComposerHighlightedItemId((existing) => | |
| 2705 | existing && composerMenuItems.some((item) => item.id === existing) | |
| 2706 | ? existing | |
| 2707 | : (composerMenuItems[0]?.id ?? null), | |
| 2708 | ); | |
| 2709 | }, [composerMenuItems, composerMenuOpen]); | |
| 2710 | ||
| 2711 | 2118 | useEffect(() => { |
| 2712 | 2119 | setIsRevertingCheckpoint(false); |
| 2713 | 2120 | }, [activeThread?.id]); |
| ... | ... | @@ -2722,14 +2129,6 @@ export default function ChatView(props: ChatViewProps) { |
| 2722 | 2129 | }; |
| 2723 | 2130 | }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); |
| 2724 | 2131 | |
| 2725 | useEffect(() => { | |
| 2726 | composerImagesRef.current = composerImages; | |
| 2727 | }, [composerImages]); | |
| 2728 | ||
| 2729 | useEffect(() => { | |
| 2730 | composerTerminalContextsRef.current = composerTerminalContexts; | |
| 2731 | }, [composerTerminalContexts]); | |
| 2732 | ||
| 2733 | 2132 | useEffect(() => { |
| 2734 | 2133 | if (!activeThread?.id) return; |
| 2735 | 2134 | if (activeThread.messages.length === 0) { |
| ... | ... | @@ -2758,11 +2157,6 @@ export default function ChatView(props: ChatViewProps) { |
| 2758 | 2157 | }; |
| 2759 | 2158 | }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); |
| 2760 | 2159 | |
| 2761 | useEffect(() => { | |
| 2762 | promptRef.current = prompt; | |
| 2763 | setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); | |
| 2764 | }, [prompt]); | |
| 2765 | ||
| 2766 | 2160 | useEffect(() => { |
| 2767 | 2161 | setOptimisticUserMessages((existing) => { |
| 2768 | 2162 | for (const message of existing) { |
| ... | ... | @@ -2771,128 +2165,12 @@ export default function ChatView(props: ChatViewProps) { |
| 2771 | 2165 | return []; |
| 2772 | 2166 | }); |
| 2773 | 2167 | resetLocalDispatch(); |
| 2774 | setComposerHighlightedItemId(null); | |
| 2775 | setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); | |
| 2776 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); | |
| 2777 | dragDepthRef.current = 0; | |
| 2778 | setIsDragOverComposer(false); | |
| 2779 | 2168 | setExpandedImage(null); |
| 2780 | 2169 | }, [draftId, resetLocalDispatch, threadId]); |
| 2781 | 2170 | |
| 2782 | useEffect(() => { | |
| 2783 | let cancelled = false; | |
| 2784 | void (async () => { | |
| 2785 | if (composerImages.length === 0) { | |
| 2786 | clearComposerDraftPersistedAttachments(composerDraftTarget); | |
| 2787 | return; | |
| 2788 | } | |
| 2789 | const getPersistedAttachmentsForThread = () => | |
| 2790 | getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; | |
| 2791 | try { | |
| 2792 | const currentPersistedAttachments = getPersistedAttachmentsForThread(); | |
| 2793 | const existingPersistedById = new Map( | |
| 2794 | currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), | |
| 2795 | ); | |
| 2796 | const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>(); | |
| 2797 | await Promise.all( | |
| 2798 | composerImages.map(async (image) => { | |
| 2799 | try { | |
| 2800 | const dataUrl = await readFileAsDataUrl(image.file); | |
| 2801 | stagedAttachmentById.set(image.id, { | |
| 2802 | id: image.id, | |
| 2803 | name: image.name, | |
| 2804 | mimeType: image.mimeType, | |
| 2805 | sizeBytes: image.sizeBytes, | |
| 2806 | dataUrl, | |
| 2807 | }); | |
| 2808 | } catch { | |
| 2809 | const existingPersisted = existingPersistedById.get(image.id); | |
| 2810 | if (existingPersisted) { | |
| 2811 | stagedAttachmentById.set(image.id, existingPersisted); | |
| 2812 | } | |
| 2813 | } | |
| 2814 | }), | |
| 2815 | ); | |
| 2816 | const serialized = Array.from(stagedAttachmentById.values()); | |
| 2817 | if (cancelled) { | |
| 2818 | return; | |
| 2819 | } | |
| 2820 | // Stage attachments in persisted draft state first so persist middleware can write them. | |
| 2821 | syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); | |
| 2822 | } catch { | |
| 2823 | const currentImageIds = new Set(composerImages.map((image) => image.id)); | |
| 2824 | const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); | |
| 2825 | const fallbackPersistedIds = fallbackPersistedAttachments | |
| 2826 | .map((attachment) => attachment.id) | |
| 2827 | .filter((id) => currentImageIds.has(id)); | |
| 2828 | const fallbackPersistedIdSet = new Set(fallbackPersistedIds); | |
| 2829 | const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => | |
| 2830 | fallbackPersistedIdSet.has(attachment.id), | |
| 2831 | ); | |
| 2832 | if (cancelled) { | |
| 2833 | return; | |
| 2834 | } | |
| 2835 | syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); | |
| 2836 | } | |
| 2837 | })(); | |
| 2838 | return () => { | |
| 2839 | cancelled = true; | |
| 2840 | }; | |
| 2841 | }, [ | |
| 2842 | composerDraftTarget, | |
| 2843 | clearComposerDraftPersistedAttachments, | |
| 2844 | composerImages, | |
| 2845 | getComposerDraft, | |
| 2846 | syncComposerDraftPersistedAttachments, | |
| 2847 | ]); | |
| 2848 | ||
| 2849 | 2171 | const closeExpandedImage = useCallback(() => { |
| 2850 | 2172 | setExpandedImage(null); |
| 2851 | 2173 | }, []); |
| 2852 | const navigateExpandedImage = useCallback((direction: -1 | 1) => { | |
| 2853 | setExpandedImage((existing) => { | |
| 2854 | if (!existing || existing.images.length <= 1) { | |
| 2855 | return existing; | |
| 2856 | } | |
| 2857 | const nextIndex = | |
| 2858 | (existing.index + direction + existing.images.length) % existing.images.length; | |
| 2859 | if (nextIndex === existing.index) { | |
| 2860 | return existing; | |
| 2861 | } | |
| 2862 | return { ...existing, index: nextIndex }; | |
| 2863 | }); | |
| 2864 | }, []); | |
| 2865 | ||
| 2866 | useEffect(() => { | |
| 2867 | if (!expandedImage) { | |
| 2868 | return; | |
| 2869 | } | |
| 2870 | ||
| 2871 | const onKeyDown = (event: globalThis.KeyboardEvent) => { | |
| 2872 | if (event.key === "Escape") { | |
| 2873 | event.preventDefault(); | |
| 2874 | event.stopPropagation(); | |
| 2875 | closeExpandedImage(); | |
| 2876 | return; | |
| 2877 | } | |
| 2878 | if (expandedImage.images.length <= 1) { | |
| 2879 | return; | |
| 2880 | } | |
| 2881 | if (event.key === "ArrowLeft") { | |
| 2882 | event.preventDefault(); | |
| 2883 | event.stopPropagation(); | |
| 2884 | navigateExpandedImage(-1); | |
| 2885 | return; | |
| 2886 | } | |
| 2887 | if (event.key !== "ArrowRight") return; | |
| 2888 | event.preventDefault(); | |
| 2889 | event.stopPropagation(); | |
| 2890 | navigateExpandedImage(1); | |
| 2891 | }; | |
| 2892 | ||
| 2893 | window.addEventListener("keydown", onKeyDown); | |
| 2894 | return () => window.removeEventListener("keydown", onKeyDown); | |
| 2895 | }, [closeExpandedImage, expandedImage, navigateExpandedImage]); | |
| 2896 | 2174 | |
| 2897 | 2175 | const activeWorktreePath = activeThread?.worktreePath ?? null; |
| 2898 | 2176 | const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ |
| ... | ... | @@ -3097,117 +2375,6 @@ export default function ChatView(props: ChatViewProps) { |
| 3097 | 2375 | toggleTerminalVisibility, |
| 3098 | 2376 | ]); |
| 3099 | 2377 | |
| 3100 | const addComposerImages = (files: File[]) => { | |
| 3101 | if (!activeThreadId || files.length === 0) return; | |
| 3102 | ||
| 3103 | if (pendingUserInputs.length > 0) { | |
| 3104 | toastManager.add({ | |
| 3105 | type: "error", | |
| 3106 | title: "Attach images after answering plan questions.", | |
| 3107 | }); | |
| 3108 | return; | |
| 3109 | } | |
| 3110 | ||
| 3111 | const nextImages: ComposerImageAttachment[] = []; | |
| 3112 | let nextImageCount = composerImagesRef.current.length; | |
| 3113 | let error: string | null = null; | |
| 3114 | for (const file of files) { | |
| 3115 | if (!file.type.startsWith("image/")) { | |
| 3116 | error = `Unsupported file type for '${file.name}'. Please attach image files only.`; | |
| 3117 | continue; | |
| 3118 | } | |
| 3119 | if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | |
| 3120 | error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; | |
| 3121 | continue; | |
| 3122 | } | |
| 3123 | if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | |
| 3124 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; | |
| 3125 | break; | |
| 3126 | } | |
| 3127 | ||
| 3128 | const previewUrl = URL.createObjectURL(file); | |
| 3129 | nextImages.push({ | |
| 3130 | type: "image", | |
| 3131 | id: randomUUID(), | |
| 3132 | name: file.name || "image", | |
| 3133 | mimeType: file.type, | |
| 3134 | sizeBytes: file.size, | |
| 3135 | previewUrl, | |
| 3136 | file, | |
| 3137 | }); | |
| 3138 | nextImageCount += 1; | |
| 3139 | } | |
| 3140 | ||
| 3141 | if (nextImages.length === 1 && nextImages[0]) { | |
| 3142 | addComposerImage(nextImages[0]); | |
| 3143 | } else if (nextImages.length > 1) { | |
| 3144 | addComposerImagesToDraft(nextImages); | |
| 3145 | } | |
| 3146 | setThreadError(activeThreadId, error); | |
| 3147 | }; | |
| 3148 | ||
| 3149 | const removeComposerImage = (imageId: string) => { | |
| 3150 | removeComposerImageFromDraft(imageId); | |
| 3151 | }; | |
| 3152 | ||
| 3153 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { | |
| 3154 | const files = Array.from(event.clipboardData.files); | |
| 3155 | if (files.length === 0) { | |
| 3156 | return; | |
| 3157 | } | |
| 3158 | const imageFiles = files.filter((file) => file.type.startsWith("image/")); | |
| 3159 | if (imageFiles.length === 0) { | |
| 3160 | return; | |
| 3161 | } | |
| 3162 | event.preventDefault(); | |
| 3163 | addComposerImages(imageFiles); | |
| 3164 | }; | |
| 3165 | ||
| 3166 | const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => { | |
| 3167 | if (!event.dataTransfer.types.includes("Files")) { | |
| 3168 | return; | |
| 3169 | } | |
| 3170 | event.preventDefault(); | |
| 3171 | dragDepthRef.current += 1; | |
| 3172 | setIsDragOverComposer(true); | |
| 3173 | }; | |
| 3174 | ||
| 3175 | const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => { | |
| 3176 | if (!event.dataTransfer.types.includes("Files")) { | |
| 3177 | return; | |
| 3178 | } | |
| 3179 | event.preventDefault(); | |
| 3180 | event.dataTransfer.dropEffect = "copy"; | |
| 3181 | setIsDragOverComposer(true); | |
| 3182 | }; | |
| 3183 | ||
| 3184 | const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => { | |
| 3185 | if (!event.dataTransfer.types.includes("Files")) { | |
| 3186 | return; | |
| 3187 | } | |
| 3188 | event.preventDefault(); | |
| 3189 | const nextTarget = event.relatedTarget; | |
| 3190 | if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { | |
| 3191 | return; | |
| 3192 | } | |
| 3193 | dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); | |
| 3194 | if (dragDepthRef.current === 0) { | |
| 3195 | setIsDragOverComposer(false); | |
| 3196 | } | |
| 3197 | }; | |
| 3198 | ||
| 3199 | const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => { | |
| 3200 | if (!event.dataTransfer.types.includes("Files")) { | |
| 3201 | return; | |
| 3202 | } | |
| 3203 | event.preventDefault(); | |
| 3204 | dragDepthRef.current = 0; | |
| 3205 | setIsDragOverComposer(false); | |
| 3206 | const files = Array.from(event.dataTransfer.files); | |
| 3207 | addComposerImages(files); | |
| 3208 | focusComposer(); | |
| 3209 | }; | |
| 3210 | ||
| 3211 | 2378 | const onRevertToTurnCount = useCallback( |
| 3212 | 2379 | async (turnCount: number) => { |
| 3213 | 2380 | const api = readEnvironmentApi(environmentId); |
| ... | ... | @@ -3266,6 +2433,17 @@ export default function ChatView(props: ChatViewProps) { |
| 3266 | 2433 | onAdvanceActivePendingUserInput(); |
| 3267 | 2434 | return; |
| 3268 | 2435 | } |
| 2436 | const sendCtx = composerRef.current?.getSendContext(); | |
| 2437 | if (!sendCtx) return; | |
| 2438 | const { | |
| 2439 | images: composerImages, | |
| 2440 | terminalContexts: composerTerminalContexts, | |
| 2441 | selectedProvider: ctxSelectedProvider, | |
| 2442 | selectedModel: ctxSelectedModel, | |
| 2443 | selectedProviderModels: ctxSelectedProviderModels, | |
| 2444 | selectedPromptEffort: ctxSelectedPromptEffort, | |
| 2445 | selectedModelSelection: ctxSelectedModelSelection, | |
| 2446 | } = sendCtx; | |
| 3269 | 2447 | const promptForSend = promptRef.current; |
| 3270 | 2448 | const { |
| 3271 | 2449 | trimmedPrompt: trimmed, |
| ... | ... | @@ -3283,10 +2461,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3283 | 2461 | planMarkdown: activeProposedPlan.planMarkdown, |
| 3284 | 2462 | }); |
| 3285 | 2463 | promptRef.current = ""; |
| 3286 | clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); | |
| 3287 | setComposerHighlightedItemId(null); | |
| 3288 | setComposerCursor(0); | |
| 3289 | setComposerTrigger(null); | |
| 2464 | clearComposerDraftContent(composerDraftTarget); | |
| 2465 | composerRef.current?.resetCursorState(); | |
| 3290 | 2466 | await onSubmitPlanFollowUp({ |
| 3291 | 2467 | text: followUp.text, |
| 3292 | 2468 | interactionMode: followUp.interactionMode, |
| ... | ... | @@ -3300,10 +2476,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3300 | 2476 | if (standaloneSlashCommand) { |
| 3301 | 2477 | handleInteractionModeChange(standaloneSlashCommand); |
| 3302 | 2478 | promptRef.current = ""; |
| 3303 | clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); | |
| 3304 | setComposerHighlightedItemId(null); | |
| 3305 | setComposerCursor(0); | |
| 3306 | setComposerTrigger(null); | |
| 2479 | clearComposerDraftContent(composerDraftTarget); | |
| 2480 | composerRef.current?.resetCursorState(); | |
| 3307 | 2481 | return; |
| 3308 | 2482 | } |
| 3309 | 2483 | if (!hasSendableContent) { |
| ... | ... | @@ -3349,10 +2523,10 @@ export default function ChatView(props: ChatViewProps) { |
| 3349 | 2523 | const messageIdForSend = newMessageId(); |
| 3350 | 2524 | const messageCreatedAt = new Date().toISOString(); |
| 3351 | 2525 | const outgoingMessageText = formatOutgoingPrompt({ |
| 3352 | provider: selectedProvider, | |
| 3353 | model: selectedModel, | |
| 3354 | models: selectedProviderModels, | |
| 3355 | effort: selectedPromptEffort, | |
| 2526 | provider: ctxSelectedProvider, | |
| 2527 | model: ctxSelectedModel, | |
| 2528 | models: ctxSelectedProviderModels, | |
| 2529 | effort: ctxSelectedPromptEffort, | |
| 3356 | 2530 | text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, |
| 3357 | 2531 | }); |
| 3358 | 2532 | const turnAttachmentsPromise = Promise.all( |
| ... | ... | @@ -3400,10 +2574,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3400 | 2574 | }); |
| 3401 | 2575 | } |
| 3402 | 2576 | promptRef.current = ""; |
| 3403 | clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend)); | |
| 3404 | setComposerHighlightedItemId(null); | |
| 3405 | setComposerCursor(0); | |
| 3406 | setComposerTrigger(null); | |
| 2577 | clearComposerDraftContent(composerDraftTarget); | |
| 2578 | composerRef.current?.resetCursorState(); | |
| 3407 | 2579 | |
| 3408 | 2580 | let turnStartSucceeded = false; |
| 3409 | 2581 | await (async () => { |
| ... | ... | @@ -3426,12 +2598,14 @@ export default function ChatView(props: ChatViewProps) { |
| 3426 | 2598 | } |
| 3427 | 2599 | const title = truncate(titleSeed); |
| 3428 | 2600 | const threadCreateModelSelection: ModelSelection = { |
| 3429 | provider: selectedProvider, | |
| 2601 | provider: ctxSelectedProvider, | |
| 3430 | 2602 | model: |
| 3431 | selectedModel || | |
| 2603 | ctxSelectedModel || | |
| 3432 | 2604 | activeProject.defaultModelSelection?.model || |
| 3433 | 2605 | DEFAULT_MODEL_BY_PROVIDER.codex, |
| 3434 | ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), | |
| 2606 | ...(ctxSelectedModelSelection.options | |
| 2607 | ? { options: ctxSelectedModelSelection.options } | |
| 2608 | : {}), | |
| 3435 | 2609 | }; |
| 3436 | 2610 | |
| 3437 | 2611 | // Auto-title from first message |
| ... | ... | @@ -3448,7 +2622,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3448 | 2622 | await persistThreadSettingsForNextTurn({ |
| 3449 | 2623 | threadId: threadIdForSend, |
| 3450 | 2624 | createdAt: messageCreatedAt, |
| 3451 | ...(selectedModel ? { modelSelection: selectedModelSelection } : {}), | |
| 2625 | ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), | |
| 3452 | 2626 | runtimeMode, |
| 3453 | 2627 | interactionMode, |
| 3454 | 2628 | }); |
| ... | ... | @@ -3495,7 +2669,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3495 | 2669 | text: outgoingMessageText, |
| 3496 | 2670 | attachments: turnAttachments, |
| 3497 | 2671 | }, |
| 3498 | modelSelection: selectedModelSelection, | |
| 2672 | modelSelection: ctxSelectedModelSelection, | |
| 3499 | 2673 | titleSeed: title, |
| 3500 | 2674 | runtimeMode, |
| 3501 | 2675 | interactionMode, |
| ... | ... | @@ -3519,11 +2693,17 @@ export default function ChatView(props: ChatViewProps) { |
| 3519 | 2693 | return next.length === existing.length ? existing : next; |
| 3520 | 2694 | }); |
| 3521 | 2695 | promptRef.current = promptForSend; |
| 3522 | setPrompt(promptForSend); | |
| 3523 | setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); | |
| 3524 | addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); | |
| 3525 | addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); | |
| 3526 | setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); | |
| 2696 | const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); | |
| 2697 | composerImagesRef.current = retryComposerImages; | |
| 2698 | composerTerminalContextsRef.current = composerTerminalContextsSnapshot; | |
| 2699 | setComposerDraftPrompt(composerDraftTarget, promptForSend); | |
| 2700 | addComposerDraftImages(composerDraftTarget, retryComposerImages); | |
| 2701 | setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); | |
| 2702 | composerRef.current?.resetCursorState({ | |
| 2703 | cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), | |
| 2704 | prompt: promptForSend, | |
| 2705 | detectTrigger: true, | |
| 2706 | }); | |
| 3527 | 2707 | } |
| 3528 | 2708 | setThreadError( |
| 3529 | 2709 | threadIdForSend, |
| ... | ... | @@ -3644,8 +2824,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3644 | 2824 | }; |
| 3645 | 2825 | }); |
| 3646 | 2826 | promptRef.current = ""; |
| 3647 | setComposerCursor(0); | |
| 3648 | setComposerTrigger(null); | |
| 2827 | composerRef.current?.resetCursorState({ cursor: 0 }); | |
| 3649 | 2828 | }, |
| 3650 | 2829 | [activePendingProgress?.activeQuestion, activePendingUserInput], |
| 3651 | 2830 | ); |
| ... | ... | @@ -3656,7 +2835,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3656 | 2835 | value: string, |
| 3657 | 2836 | nextCursor: number, |
| 3658 | 2837 | expandedCursor: number, |
| 3659 | cursorAdjacentToMention: boolean, | |
| 2838 | _cursorAdjacentToMention: boolean, | |
| 3660 | 2839 | ) => { |
| 3661 | 2840 | if (!activePendingUserInput) { |
| 3662 | 2841 | return; |
| ... | ... | @@ -3672,10 +2851,14 @@ export default function ChatView(props: ChatViewProps) { |
| 3672 | 2851 | ), |
| 3673 | 2852 | }, |
| 3674 | 2853 | })); |
| 3675 | setComposerCursor(nextCursor); | |
| 3676 | setComposerTrigger( | |
| 3677 | cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), | |
| 3678 | ); | |
| 2854 | const snapshot = composerRef.current?.readSnapshot(); | |
| 2855 | if ( | |
| 2856 | snapshot?.value !== value || | |
| 2857 | snapshot.cursor !== nextCursor || | |
| 2858 | snapshot.expandedCursor !== expandedCursor | |
| 2859 | ) { | |
| 2860 | composerRef.current?.focusAt(nextCursor); | |
| 2861 | } | |
| 3679 | 2862 | }, |
| 3680 | 2863 | [activePendingUserInput], |
| 3681 | 2864 | ); |
| ... | ... | @@ -3731,14 +2914,26 @@ export default function ChatView(props: ChatViewProps) { |
| 3731 | 2914 | return; |
| 3732 | 2915 | } |
| 3733 | 2916 | |
| 2917 | const sendCtx = composerRef.current?.getSendContext(); | |
| 2918 | if (!sendCtx) { | |
| 2919 | return; | |
| 2920 | } | |
| 2921 | const { | |
| 2922 | selectedProvider: ctxSelectedProvider, | |
| 2923 | selectedModel: ctxSelectedModel, | |
| 2924 | selectedProviderModels: ctxSelectedProviderModels, | |
| 2925 | selectedPromptEffort: ctxSelectedPromptEffort, | |
| 2926 | selectedModelSelection: ctxSelectedModelSelection, | |
| 2927 | } = sendCtx; | |
| 2928 | ||
| 3734 | 2929 | const threadIdForSend = activeThread.id; |
| 3735 | 2930 | const messageIdForSend = newMessageId(); |
| 3736 | 2931 | const messageCreatedAt = new Date().toISOString(); |
| 3737 | 2932 | const outgoingMessageText = formatOutgoingPrompt({ |
| 3738 | provider: selectedProvider, | |
| 3739 | model: selectedModel, | |
| 3740 | models: selectedProviderModels, | |
| 3741 | effort: selectedPromptEffort, | |
| 2933 | provider: ctxSelectedProvider, | |
| 2934 | model: ctxSelectedModel, | |
| 2935 | models: ctxSelectedProviderModels, | |
| 2936 | effort: ctxSelectedPromptEffort, | |
| 3742 | 2937 | text: trimmed, |
| 3743 | 2938 | }); |
| 3744 | 2939 | |
| ... | ... | @@ -3762,7 +2957,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3762 | 2957 | await persistThreadSettingsForNextTurn({ |
| 3763 | 2958 | threadId: threadIdForSend, |
| 3764 | 2959 | createdAt: messageCreatedAt, |
| 3765 | modelSelection: selectedModelSelection, | |
| 2960 | modelSelection: ctxSelectedModelSelection, | |
| 3766 | 2961 | runtimeMode, |
| 3767 | 2962 | interactionMode: nextInteractionMode, |
| 3768 | 2963 | }); |
| ... | ... | @@ -3784,7 +2979,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3784 | 2979 | text: outgoingMessageText, |
| 3785 | 2980 | attachments: [], |
| 3786 | 2981 | }, |
| 3787 | modelSelection: selectedModelSelection, | |
| 2982 | modelSelection: ctxSelectedModelSelection, | |
| 3788 | 2983 | titleSeed: activeThread.title, |
| 3789 | 2984 | runtimeMode, |
| 3790 | 2985 | interactionMode: nextInteractionMode, |
| ... | ... | @@ -3829,13 +3024,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3829 | 3024 | persistThreadSettingsForNextTurn, |
| 3830 | 3025 | resetLocalDispatch, |
| 3831 | 3026 | runtimeMode, |
| 3832 | selectedPromptEffort, | |
| 3833 | selectedModelSelection, | |
| 3834 | selectedProvider, | |
| 3835 | selectedProviderModels, | |
| 3836 | 3027 | setComposerDraftInteractionMode, |
| 3837 | 3028 | setThreadError, |
| 3838 | selectedModel, | |
| 3839 | 3029 | environmentId, |
| 3840 | 3030 | ], |
| 3841 | 3031 | ); |
| ... | ... | @@ -3855,19 +3045,31 @@ export default function ChatView(props: ChatViewProps) { |
| 3855 | 3045 | return; |
| 3856 | 3046 | } |
| 3857 | 3047 | |
| 3048 | const sendCtx = composerRef.current?.getSendContext(); | |
| 3049 | if (!sendCtx) { | |
| 3050 | return; | |
| 3051 | } | |
| 3052 | const { | |
| 3053 | selectedProvider: ctxSelectedProvider, | |
| 3054 | selectedModel: ctxSelectedModel, | |
| 3055 | selectedProviderModels: ctxSelectedProviderModels, | |
| 3056 | selectedPromptEffort: ctxSelectedPromptEffort, | |
| 3057 | selectedModelSelection: ctxSelectedModelSelection, | |
| 3058 | } = sendCtx; | |
| 3059 | ||
| 3858 | 3060 | const createdAt = new Date().toISOString(); |
| 3859 | 3061 | const nextThreadId = newThreadId(); |
| 3860 | 3062 | const planMarkdown = activeProposedPlan.planMarkdown; |
| 3861 | 3063 | const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); |
| 3862 | 3064 | const outgoingImplementationPrompt = formatOutgoingPrompt({ |
| 3863 | provider: selectedProvider, | |
| 3864 | model: selectedModel, | |
| 3865 | models: selectedProviderModels, | |
| 3866 | effort: selectedPromptEffort, | |
| 3065 | provider: ctxSelectedProvider, | |
| 3066 | model: ctxSelectedModel, | |
| 3067 | models: ctxSelectedProviderModels, | |
| 3068 | effort: ctxSelectedPromptEffort, | |
| 3867 | 3069 | text: implementationPrompt, |
| 3868 | 3070 | }); |
| 3869 | 3071 | const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); |
| 3870 | const nextThreadModelSelection: ModelSelection = selectedModelSelection; | |
| 3072 | const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; | |
| 3871 | 3073 | |
| 3872 | 3074 | sendInFlightRef.current = true; |
| 3873 | 3075 | beginLocalDispatch({ preparingWorktree: false }); |
| ... | ... | @@ -3901,7 +3103,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3901 | 3103 | text: outgoingImplementationPrompt, |
| 3902 | 3104 | attachments: [], |
| 3903 | 3105 | }, |
| 3904 | modelSelection: selectedModelSelection, | |
| 3106 | modelSelection: ctxSelectedModelSelection, | |
| 3905 | 3107 | titleSeed: nextThreadTitle, |
| 3906 | 3108 | runtimeMode, |
| 3907 | 3109 | interactionMode: "default", |
| ... | ... | @@ -3953,11 +3155,6 @@ export default function ChatView(props: ChatViewProps) { |
| 3953 | 3155 | navigate, |
| 3954 | 3156 | resetLocalDispatch, |
| 3955 | 3157 | runtimeMode, |
| 3956 | selectedPromptEffort, | |
| 3957 | selectedModelSelection, | |
| 3958 | selectedProvider, | |
| 3959 | selectedProviderModels, | |
| 3960 | selectedModel, | |
| 3961 | 3158 | environmentId, |
| 3962 | 3159 | ]); |
| 3963 | 3160 | |
| ... | ... | @@ -3996,42 +3193,6 @@ export default function ChatView(props: ChatViewProps) { |
| 3996 | 3193 | settings, |
| 3997 | 3194 | ], |
| 3998 | 3195 | ); |
| 3999 | const setPromptFromTraits = useCallback( | |
| 4000 | (nextPrompt: string) => { | |
| 4001 | const currentPrompt = promptRef.current; | |
| 4002 | if (nextPrompt === currentPrompt) { | |
| 4003 | scheduleComposerFocus(); | |
| 4004 | return; | |
| 4005 | } | |
| 4006 | promptRef.current = nextPrompt; | |
| 4007 | setPrompt(nextPrompt); | |
| 4008 | const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); | |
| 4009 | setComposerCursor(nextCursor); | |
| 4010 | setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); | |
| 4011 | scheduleComposerFocus(); | |
| 4012 | }, | |
| 4013 | [scheduleComposerFocus, setPrompt], | |
| 4014 | ); | |
| 4015 | const providerTraitsMenuContent = renderProviderTraitsMenuContent({ | |
| 4016 | provider: selectedProvider, | |
| 4017 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), | |
| 4018 | ...(routeKind === "draft" && draftId ? { draftId } : {}), | |
| 4019 | model: selectedModel, | |
| 4020 | models: selectedProviderModels, | |
| 4021 | modelOptions: composerModelOptions?.[selectedProvider], | |
| 4022 | prompt, | |
| 4023 | onPromptChange: setPromptFromTraits, | |
| 4024 | }); | |
| 4025 | const providerTraitsPicker = renderProviderTraitsPicker({ | |
| 4026 | provider: selectedProvider, | |
| 4027 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), | |
| 4028 | ...(routeKind === "draft" && draftId ? { draftId } : {}), | |
| 4029 | model: selectedModel, | |
| 4030 | models: selectedProviderModels, | |
| 4031 | modelOptions: composerModelOptions?.[selectedProvider], | |
| 4032 | prompt, | |
| 4033 | onPromptChange: setPromptFromTraits, | |
| 4034 | }); | |
| 4035 | 3196 | const onEnvModeChange = useCallback( |
| 4036 | 3197 | (mode: DraftThreadEnvMode) => { |
| 4037 | 3198 | if (isLocalDraftThread) { |
| ... | ... | @@ -4051,257 +3212,6 @@ export default function ChatView(props: ChatViewProps) { |
| 4051 | 3212 | ], |
| 4052 | 3213 | ); |
| 4053 | 3214 | |
| 4054 | const applyPromptReplacement = useCallback( | |
| 4055 | ( | |
| 4056 | rangeStart: number, | |
| 4057 | rangeEnd: number, | |
| 4058 | replacement: string, | |
| 4059 | options?: { expectedText?: string }, | |
| 4060 | ): boolean => { | |
| 4061 | const currentText = promptRef.current; | |
| 4062 | const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); | |
| 4063 | const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); | |
| 4064 | if ( | |
| 4065 | options?.expectedText !== undefined && | |
| 4066 | currentText.slice(safeStart, safeEnd) !== options.expectedText | |
| 4067 | ) { | |
| 4068 | return false; | |
| 4069 | } | |
| 4070 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); | |
| 4071 | const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); | |
| 4072 | promptRef.current = next.text; | |
| 4073 | const activePendingQuestion = activePendingProgress?.activeQuestion; | |
| 4074 | if (activePendingQuestion && activePendingUserInput) { | |
| 4075 | setPendingUserInputAnswersByRequestId((existing) => ({ | |
| 4076 | ...existing, | |
| 4077 | [activePendingUserInput.requestId]: { | |
| 4078 | ...existing[activePendingUserInput.requestId], | |
| 4079 | [activePendingQuestion.id]: setPendingUserInputCustomAnswer( | |
| 4080 | existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], | |
| 4081 | next.text, | |
| 4082 | ), | |
| 4083 | }, | |
| 4084 | })); | |
| 4085 | } else { | |
| 4086 | setPrompt(next.text); | |
| 4087 | } | |
| 4088 | setComposerCursor(nextCursor); | |
| 4089 | setComposerTrigger( | |
| 4090 | detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), | |
| 4091 | ); | |
| 4092 | window.requestAnimationFrame(() => { | |
| 4093 | composerEditorRef.current?.focusAt(nextCursor); | |
| 4094 | }); | |
| 4095 | return true; | |
| 4096 | }, | |
| 4097 | [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], | |
| 4098 | ); | |
| 4099 | ||
| 4100 | const readComposerSnapshot = useCallback((): { | |
| 4101 | value: string; | |
| 4102 | cursor: number; | |
| 4103 | expandedCursor: number; | |
| 4104 | terminalContextIds: string[]; | |
| 4105 | } => { | |
| 4106 | const editorSnapshot = composerEditorRef.current?.readSnapshot(); | |
| 4107 | if (editorSnapshot) { | |
| 4108 | return editorSnapshot; | |
| 4109 | } | |
| 4110 | return { | |
| 4111 | value: promptRef.current, | |
| 4112 | cursor: composerCursor, | |
| 4113 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), | |
| 4114 | terminalContextIds: composerTerminalContexts.map((context) => context.id), | |
| 4115 | }; | |
| 4116 | }, [composerCursor, composerTerminalContexts]); | |
| 4117 | ||
| 4118 | const resolveActiveComposerTrigger = useCallback((): { | |
| 4119 | snapshot: { value: string; cursor: number; expandedCursor: number }; | |
| 4120 | trigger: ComposerTrigger | null; | |
| 4121 | } => { | |
| 4122 | const snapshot = readComposerSnapshot(); | |
| 4123 | return { | |
| 4124 | snapshot, | |
| 4125 | trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), | |
| 4126 | }; | |
| 4127 | }, [readComposerSnapshot]); | |
| 4128 | ||
| 4129 | const onSelectComposerItem = useCallback( | |
| 4130 | (item: ComposerCommandItem) => { | |
| 4131 | if (composerSelectLockRef.current) return; | |
| 4132 | composerSelectLockRef.current = true; | |
| 4133 | window.requestAnimationFrame(() => { | |
| 4134 | composerSelectLockRef.current = false; | |
| 4135 | }); | |
| 4136 | const { snapshot, trigger } = resolveActiveComposerTrigger(); | |
| 4137 | if (!trigger) return; | |
| 4138 | if (item.type === "path") { | |
| 4139 | const replacement = `@${item.path} `; | |
| 4140 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | |
| 4141 | snapshot.value, | |
| 4142 | trigger.rangeEnd, | |
| 4143 | replacement, | |
| 4144 | ); | |
| 4145 | const applied = applyPromptReplacement( | |
| 4146 | trigger.rangeStart, | |
| 4147 | replacementRangeEnd, | |
| 4148 | replacement, | |
| 4149 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, | |
| 4150 | ); | |
| 4151 | if (applied) { | |
| 4152 | setComposerHighlightedItemId(null); | |
| 4153 | } | |
| 4154 | return; | |
| 4155 | } | |
| 4156 | if (item.type === "slash-command") { | |
| 4157 | if (item.command === "model") { | |
| 4158 | const replacement = "/model "; | |
| 4159 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | |
| 4160 | snapshot.value, | |
| 4161 | trigger.rangeEnd, | |
| 4162 | replacement, | |
| 4163 | ); | |
| 4164 | const applied = applyPromptReplacement( | |
| 4165 | trigger.rangeStart, | |
| 4166 | replacementRangeEnd, | |
| 4167 | replacement, | |
| 4168 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, | |
| 4169 | ); | |
| 4170 | if (applied) { | |
| 4171 | setComposerHighlightedItemId(null); | |
| 4172 | } | |
| 4173 | return; | |
| 4174 | } | |
| 4175 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); | |
| 4176 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | |
| 4177 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), | |
| 4178 | }); | |
| 4179 | if (applied) { | |
| 4180 | setComposerHighlightedItemId(null); | |
| 4181 | } | |
| 4182 | return; | |
| 4183 | } | |
| 4184 | onProviderModelSelect(item.provider, item.model); | |
| 4185 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | |
| 4186 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), | |
| 4187 | }); | |
| 4188 | if (applied) { | |
| 4189 | setComposerHighlightedItemId(null); | |
| 4190 | } | |
| 4191 | }, | |
| 4192 | [ | |
| 4193 | applyPromptReplacement, | |
| 4194 | handleInteractionModeChange, | |
| 4195 | onProviderModelSelect, | |
| 4196 | resolveActiveComposerTrigger, | |
| 4197 | ], | |
| 4198 | ); | |
| 4199 | const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { | |
| 4200 | setComposerHighlightedItemId(itemId); | |
| 4201 | }, []); | |
| 4202 | const nudgeComposerMenuHighlight = useCallback( | |
| 4203 | (key: "ArrowDown" | "ArrowUp") => { | |
| 4204 | if (composerMenuItems.length === 0) { | |
| 4205 | return; | |
| 4206 | } | |
| 4207 | const highlightedIndex = composerMenuItems.findIndex( | |
| 4208 | (item) => item.id === composerHighlightedItemId, | |
| 4209 | ); | |
| 4210 | const normalizedIndex = | |
| 4211 | highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; | |
| 4212 | const offset = key === "ArrowDown" ? 1 : -1; | |
| 4213 | const nextIndex = | |
| 4214 | (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; | |
| 4215 | const nextItem = composerMenuItems[nextIndex]; | |
| 4216 | setComposerHighlightedItemId(nextItem?.id ?? null); | |
| 4217 | }, | |
| 4218 | [composerHighlightedItemId, composerMenuItems], | |
| 4219 | ); | |
| 4220 | const isComposerMenuLoading = | |
| 4221 | composerTriggerKind === "path" && | |
| 4222 | ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || | |
| 4223 | workspaceEntriesQuery.isLoading || | |
| 4224 | workspaceEntriesQuery.isFetching); | |
| 4225 | ||
| 4226 | const onPromptChange = useCallback( | |
| 4227 | ( | |
| 4228 | nextPrompt: string, | |
| 4229 | nextCursor: number, | |
| 4230 | expandedCursor: number, | |
| 4231 | cursorAdjacentToMention: boolean, | |
| 4232 | terminalContextIds: string[], | |
| 4233 | ) => { | |
| 4234 | if (activePendingProgress?.activeQuestion && activePendingUserInput) { | |
| 4235 | onChangeActivePendingUserInputCustomAnswer( | |
| 4236 | activePendingProgress.activeQuestion.id, | |
| 4237 | nextPrompt, | |
| 4238 | nextCursor, | |
| 4239 | expandedCursor, | |
| 4240 | cursorAdjacentToMention, | |
| 4241 | ); | |
| 4242 | return; | |
| 4243 | } | |
| 4244 | promptRef.current = nextPrompt; | |
| 4245 | setPrompt(nextPrompt); | |
| 4246 | if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { | |
| 4247 | setComposerDraftTerminalContexts( | |
| 4248 | composerDraftTarget, | |
| 4249 | syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), | |
| 4250 | ); | |
| 4251 | } | |
| 4252 | setComposerCursor(nextCursor); | |
| 4253 | setComposerTrigger( | |
| 4254 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), | |
| 4255 | ); | |
| 4256 | }, | |
| 4257 | [ | |
| 4258 | activePendingProgress?.activeQuestion, | |
| 4259 | activePendingUserInput, | |
| 4260 | composerTerminalContexts, | |
| 4261 | onChangeActivePendingUserInputCustomAnswer, | |
| 4262 | setPrompt, | |
| 4263 | composerDraftTarget, | |
| 4264 | setComposerDraftTerminalContexts, | |
| 4265 | ], | |
| 4266 | ); | |
| 4267 | ||
| 4268 | const onComposerCommandKey = ( | |
| 4269 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | |
| 4270 | event: KeyboardEvent, | |
| 4271 | ) => { | |
| 4272 | if (key === "Tab" && event.shiftKey) { | |
| 4273 | toggleInteractionMode(); | |
| 4274 | return true; | |
| 4275 | } | |
| 4276 | ||
| 4277 | const { trigger } = resolveActiveComposerTrigger(); | |
| 4278 | const menuIsActive = composerMenuOpenRef.current || trigger !== null; | |
| 4279 | ||
| 4280 | if (menuIsActive) { | |
| 4281 | const currentItems = composerMenuItemsRef.current; | |
| 4282 | if (key === "ArrowDown" && currentItems.length > 0) { | |
| 4283 | nudgeComposerMenuHighlight("ArrowDown"); | |
| 4284 | return true; | |
| 4285 | } | |
| 4286 | if (key === "ArrowUp" && currentItems.length > 0) { | |
| 4287 | nudgeComposerMenuHighlight("ArrowUp"); | |
| 4288 | return true; | |
| 4289 | } | |
| 4290 | if (key === "Tab" || key === "Enter") { | |
| 4291 | const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; | |
| 4292 | if (selectedItem) { | |
| 4293 | onSelectComposerItem(selectedItem); | |
| 4294 | return true; | |
| 4295 | } | |
| 4296 | } | |
| 4297 | } | |
| 4298 | ||
| 4299 | if (key === "Enter" && !event.shiftKey) { | |
| 4300 | void onSend(); | |
| 4301 | return true; | |
| 4302 | } | |
| 4303 | return false; | |
| 4304 | }; | |
| 4305 | 3215 | const onToggleWorkGroup = useCallback((groupId: string) => { |
| 4306 | 3216 | setExpandedWorkGroups((existing) => ({ |
| 4307 | 3217 | ...existing, |
| ... | ... | @@ -4311,7 +3221,6 @@ export default function ChatView(props: ChatViewProps) { |
| 4311 | 3221 | const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { |
| 4312 | 3222 | setExpandedImage(preview); |
| 4313 | 3223 | }, []); |
| 4314 | const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; | |
| 4315 | 3224 | const onOpenTurnDiff = useCallback( |
| 4316 | 3225 | (turnId: TurnId, filePath?: string) => { |
| 4317 | 3226 | if (!isServerThread) { |
| ... | ... | @@ -4333,13 +3242,16 @@ export default function ChatView(props: ChatViewProps) { |
| 4333 | 3242 | }, |
| 4334 | 3243 | [environmentId, isServerThread, navigate, threadId], |
| 4335 | 3244 | ); |
| 4336 | const onRevertUserMessage = (messageId: MessageId) => { | |
| 4337 | const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); | |
| 4338 | if (typeof targetTurnCount !== "number") { | |
| 4339 | return; | |
| 4340 | } | |
| 4341 | void onRevertToTurnCount(targetTurnCount); | |
| 4342 | }; | |
| 3245 | const onRevertUserMessage = useCallback( | |
| 3246 | (messageId: MessageId) => { | |
| 3247 | const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); | |
| 3248 | if (typeof targetTurnCount !== "number") { | |
| 3249 | return; | |
| 3250 | } | |
| 3251 | void onRevertToTurnCount(targetTurnCount); | |
| 3252 | }, | |
| 3253 | [onRevertToTurnCount, revertTurnCountByUserMessageId], | |
| 3254 | ); | |
| 4343 | 3255 | |
| 4344 | 3256 | // Empty state: no active thread |
| 4345 | 3257 | if (!activeThread) { |
| ... | ... | @@ -4374,9 +3286,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4374 | 3286 | diffToggleShortcutLabel={diffPanelShortcutLabel} |
| 4375 | 3287 | gitCwd={gitCwd} |
| 4376 | 3288 | diffOpen={diffOpen} |
| 4377 | onRunProjectScript={(script) => { | |
| 4378 | void runProjectScript(script); | |
| 4379 | }} | |
| 3289 | onRunProjectScript={runProjectScript} | |
| 4380 | 3290 | onAddProjectScript={saveProjectScript} |
| 4381 | 3291 | onUpdateProjectScript={updateProjectScript} |
| 4382 | 3292 | onDeleteProjectScript={deleteProjectScript} |
| ... | ... | @@ -4456,392 +3366,71 @@ export default function ChatView(props: ChatViewProps) { |
| 4456 | 3366 | |
| 4457 | 3367 | {/* Input bar */} |
| 4458 | 3368 | <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}> |
| 4459 | <form | |
| 4460 | ref={composerFormRef} | |
| 4461 | onSubmit={onSend} | |
| 4462 | className="mx-auto w-full min-w-0 max-w-208" | |
| 4463 | data-chat-composer-form="true" | |
| 4464 | > | |
| 4465 | <div | |
| 4466 | className={cn( | |
| 4467 | "group rounded-[22px] p-px transition-colors duration-200", | |
| 4468 | composerProviderState.composerFrameClassName, | |
| 4469 | )} | |
| 4470 | onDragEnter={onComposerDragEnter} | |
| 4471 | onDragOver={onComposerDragOver} | |
| 4472 | onDragLeave={onComposerDragLeave} | |
| 4473 | onDrop={onComposerDrop} | |
| 4474 | > | |
| 4475 | <div | |
| 4476 | className={cn( | |
| 4477 | "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45", | |
| 4478 | isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border", | |
| 4479 | composerProviderState.composerSurfaceClassName, | |
| 4480 | )} | |
| 4481 | > | |
| 4482 | {activePendingApproval ? ( | |
| 4483 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> | |
| 4484 | <ComposerPendingApprovalPanel | |
| 4485 | approval={activePendingApproval} | |
| 4486 | pendingCount={pendingApprovals.length} | |
| 4487 | /> | |
| 4488 | </div> | |
| 4489 | ) : pendingUserInputs.length > 0 ? ( | |
| 4490 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> | |
| 4491 | <ComposerPendingUserInputPanel | |
| 4492 | pendingUserInputs={pendingUserInputs} | |
| 4493 | respondingRequestIds={respondingRequestIds} | |
| 4494 | answers={activePendingDraftAnswers} | |
| 4495 | questionIndex={activePendingQuestionIndex} | |
| 4496 | onToggleOption={onSelectActivePendingUserInputOption} | |
| 4497 | onAdvance={onAdvanceActivePendingUserInput} | |
| 4498 | /> | |
| 4499 | </div> | |
| 4500 | ) : showPlanFollowUpPrompt && activeProposedPlan ? ( | |
| 4501 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> | |
| 4502 | <ComposerPlanFollowUpBanner | |
| 4503 | key={activeProposedPlan.id} | |
| 4504 | planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null} | |
| 4505 | /> | |
| 4506 | </div> | |
| 4507 | ) : null} | |
| 4508 | <div | |
| 4509 | className={cn( | |
| 4510 | "relative px-3 pb-2 sm:px-4", | |
| 4511 | hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4", | |
| 4512 | )} | |
| 4513 | > | |
| 4514 | {composerMenuOpen && !isComposerApprovalState && ( | |
| 4515 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> | |
| 4516 | <ComposerCommandMenu | |
| 4517 | items={composerMenuItems} | |
| 4518 | resolvedTheme={resolvedTheme} | |
| 4519 | isLoading={isComposerMenuLoading} | |
| 4520 | triggerKind={composerTriggerKind} | |
| 4521 | activeItemId={activeComposerMenuItem?.id ?? null} | |
| 4522 | onHighlightedItemChange={onComposerMenuItemHighlighted} | |
| 4523 | onSelect={onSelectComposerItem} | |
| 4524 | /> | |
| 4525 | </div> | |
| 4526 | )} | |
| 4527 | ||
| 4528 | {!isComposerApprovalState && | |
| 4529 | pendingUserInputs.length === 0 && | |
| 4530 | composerImages.length > 0 && ( | |
| 4531 | <div className="mb-3 flex flex-wrap gap-2"> | |
| 4532 | {composerImages.map((image) => ( | |
| 4533 | <div | |
| 4534 | key={image.id} | |
| 4535 | className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" | |
| 4536 | > | |
| 4537 | {image.previewUrl ? ( | |
| 4538 | <button | |
| 4539 | type="button" | |
| 4540 | className="h-full w-full cursor-zoom-in" | |
| 4541 | aria-label={`Preview ${image.name}`} | |
| 4542 | onClick={() => { | |
| 4543 | const preview = buildExpandedImagePreview( | |
| 4544 | composerImages, | |
| 4545 | image.id, | |
| 4546 | ); | |
| 4547 | if (!preview) return; | |
| 4548 | setExpandedImage(preview); | |
| 4549 | }} | |
| 4550 | > | |
| 4551 | <img | |
| 4552 | src={image.previewUrl} | |
| 4553 | alt={image.name} | |
| 4554 | className="h-full w-full object-cover" | |
| 4555 | /> | |
| 4556 | </button> | |
| 4557 | ) : ( | |
| 4558 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70"> | |
| 4559 | {image.name} | |
| 4560 | </div> | |
| 4561 | )} | |
| 4562 | {nonPersistedComposerImageIdSet.has(image.id) && ( | |
| 4563 | <Tooltip> | |
| 4564 | <TooltipTrigger | |
| 4565 | render={ | |
| 4566 | <span | |
| 4567 | role="img" | |
| 4568 | aria-label="Draft attachment may not persist" | |
| 4569 | className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600" | |
| 4570 | > | |
| 4571 | <CircleAlertIcon className="size-3" /> | |
| 4572 | </span> | |
| 4573 | } | |
| 4574 | /> | |
| 4575 | <TooltipPopup | |
| 4576 | side="top" | |
| 4577 | className="max-w-64 whitespace-normal leading-tight" | |
| 4578 | > | |
| 4579 | Draft attachment could not be saved locally and may be lost on | |
| 4580 | navigation. | |
| 4581 | </TooltipPopup> | |
| 4582 | </Tooltip> | |
| 4583 | )} | |
| 4584 | <Button | |
| 4585 | variant="ghost" | |
| 4586 | size="icon-xs" | |
| 4587 | className="absolute right-1 top-1 bg-background/80 hover:bg-background/90" | |
| 4588 | onClick={() => removeComposerImage(image.id)} | |
| 4589 | aria-label={`Remove ${image.name}`} | |
| 4590 | > | |
| 4591 | <XIcon /> | |
| 4592 | </Button> | |
| 4593 | </div> | |
| 4594 | ))} | |
| 4595 | </div> | |
| 4596 | )} | |
| 4597 | <ComposerPromptEditor | |
| 4598 | ref={composerEditorRef} | |
| 4599 | value={ | |
| 4600 | isComposerApprovalState | |
| 4601 | ? "" | |
| 4602 | : activePendingProgress | |
| 4603 | ? activePendingProgress.customAnswer | |
| 4604 | : prompt | |
| 4605 | } | |
| 4606 | cursor={composerCursor} | |
| 4607 | terminalContexts={ | |
| 4608 | !isComposerApprovalState && pendingUserInputs.length === 0 | |
| 4609 | ? composerTerminalContexts | |
| 4610 | : [] | |
| 4611 | } | |
| 4612 | onRemoveTerminalContext={removeComposerTerminalContextFromDraft} | |
| 4613 | onChange={onPromptChange} | |
| 4614 | onCommandKeyDown={onComposerCommandKey} | |
| 4615 | onPaste={onComposerPaste} | |
| 4616 | placeholder={ | |
| 4617 | isComposerApprovalState | |
| 4618 | ? (activePendingApproval?.detail ?? | |
| 4619 | "Resolve this approval request to continue") | |
| 4620 | : activePendingProgress | |
| 4621 | ? "Type your own answer, or leave this blank to use the selected option" | |
| 4622 | : showPlanFollowUpPrompt && activeProposedPlan | |
| 4623 | ? "Add feedback to refine the plan, or leave this blank to implement it" | |
| 4624 | : phase === "disconnected" | |
| 4625 | ? "Ask for follow-up changes or attach images" | |
| 4626 | : "Ask anything, @tag files/folders, or use / to show available commands" | |
| 4627 | } | |
| 4628 | disabled={isConnecting || isComposerApprovalState} | |
| 4629 | /> | |
| 4630 | </div> | |
| 4631 | ||
| 4632 | {/* Bottom toolbar */} | |
| 4633 | {activePendingApproval ? ( | |
| 4634 | <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3"> | |
| 4635 | <ComposerPendingApprovalActions | |
| 4636 | requestId={activePendingApproval.requestId} | |
| 4637 | isResponding={respondingRequestIds.includes( | |
| 4638 | activePendingApproval.requestId, | |
| 4639 | )} | |
| 4640 | onRespondToApproval={onRespondToApproval} | |
| 4641 | /> | |
| 4642 | </div> | |
| 4643 | ) : ( | |
| 4644 | <div | |
| 4645 | ref={composerFooterRef} | |
| 4646 | data-chat-composer-footer="true" | |
| 4647 | data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"} | |
| 4648 | className={cn( | |
| 4649 | "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-hidden px-2.5 pb-2.5 sm:px-3 sm:pb-3", | |
| 4650 | isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", | |
| 4651 | )} | |
| 4652 | > | |
| 4653 | <div | |
| 4654 | ref={composerFooterLeadingRef} | |
| 4655 | className={cn( | |
| 4656 | "flex min-w-0 flex-1 items-center", | |
| 4657 | isComposerFooterCompact | |
| 4658 | ? "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" | |
| 4659 | : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible", | |
| 4660 | )} | |
| 4661 | > | |
| 4662 | {/* Provider/model picker */} | |
| 4663 | <ProviderModelPicker | |
| 4664 | compact={isComposerFooterCompact} | |
| 4665 | provider={selectedProvider} | |
| 4666 | model={selectedModelForPickerWithCustomFallback} | |
| 4667 | lockedProvider={lockedProvider} | |
| 4668 | providers={providerStatuses} | |
| 4669 | modelOptionsByProvider={modelOptionsByProvider} | |
| 4670 | {...(composerProviderState.modelPickerIconClassName | |
| 4671 | ? { | |
| 4672 | activeProviderIconClassName: | |
| 4673 | composerProviderState.modelPickerIconClassName, | |
| 4674 | } | |
| 4675 | : {})} | |
| 4676 | onProviderModelChange={onProviderModelSelect} | |
| 4677 | /> | |
| 4678 | ||
| 4679 | {isComposerFooterCompact ? ( | |
| 4680 | <CompactComposerControlsMenu | |
| 4681 | activePlan={Boolean( | |
| 4682 | activePlan || sidebarProposedPlan || planSidebarOpen, | |
| 4683 | )} | |
| 4684 | interactionMode={interactionMode} | |
| 4685 | planSidebarOpen={planSidebarOpen} | |
| 4686 | runtimeMode={runtimeMode} | |
| 4687 | traitsMenuContent={providerTraitsMenuContent} | |
| 4688 | onToggleInteractionMode={toggleInteractionMode} | |
| 4689 | onTogglePlanSidebar={togglePlanSidebar} | |
| 4690 | onRuntimeModeChange={handleRuntimeModeChange} | |
| 4691 | /> | |
| 4692 | ) : ( | |
| 4693 | <> | |
| 4694 | {providerTraitsPicker ? ( | |
| 4695 | <> | |
| 4696 | <Separator | |
| 4697 | orientation="vertical" | |
| 4698 | className="mx-0.5 hidden h-4 sm:block" | |
| 4699 | /> | |
| 4700 | {providerTraitsPicker} | |
| 4701 | </> | |
| 4702 | ) : null} | |
| 4703 | ||
| 4704 | <Separator | |
| 4705 | orientation="vertical" | |
| 4706 | className="mx-0.5 hidden h-4 sm:block" | |
| 4707 | /> | |
| 4708 | ||
| 4709 | <Button | |
| 4710 | variant="ghost" | |
| 4711 | className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | |
| 4712 | size="sm" | |
| 4713 | type="button" | |
| 4714 | onClick={toggleInteractionMode} | |
| 4715 | title={ | |
| 4716 | interactionMode === "plan" | |
| 4717 | ? "Plan mode — click to return to normal build mode" | |
| 4718 | : "Default mode — click to enter plan mode" | |
| 4719 | } | |
| 4720 | > | |
| 4721 | <BotIcon /> | |
| 4722 | <span className="sr-only sm:not-sr-only"> | |
| 4723 | {interactionMode === "plan" ? "Plan" : "Build"} | |
| 4724 | </span> | |
| 4725 | </Button> | |
| 4726 | ||
| 4727 | <Separator | |
| 4728 | orientation="vertical" | |
| 4729 | className="mx-0.5 hidden h-4 sm:block" | |
| 4730 | /> | |
| 4731 | ||
| 4732 | <Select | |
| 4733 | value={runtimeMode} | |
| 4734 | onValueChange={(value) => handleRuntimeModeChange(value!)} | |
| 4735 | > | |
| 4736 | <SelectTrigger | |
| 4737 | variant="ghost" | |
| 4738 | size="sm" | |
| 4739 | aria-label="Runtime mode" | |
| 4740 | title={runtimeModeOption.description} | |
| 4741 | > | |
| 4742 | <RuntimeModeIcon className="size-4" /> | |
| 4743 | <SelectValue>{runtimeModeOption.label}</SelectValue> | |
| 4744 | </SelectTrigger> | |
| 4745 | <SelectPopup alignItemWithTrigger={false}> | |
| 4746 | {runtimeModeOptions.map((mode) => { | |
| 4747 | const option = runtimeModeConfig[mode]; | |
| 4748 | const OptionIcon = option.icon; | |
| 4749 | return ( | |
| 4750 | <SelectItem key={mode} value={mode} className="min-w-64 py-2"> | |
| 4751 | <div className="grid min-w-0 gap-0.5"> | |
| 4752 | <span className="inline-flex items-center gap-1.5 font-medium text-foreground"> | |
| 4753 | <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 4754 | {option.label} | |
| 4755 | </span> | |
| 4756 | <span className="text-muted-foreground text-xs leading-4"> | |
| 4757 | {option.description} | |
| 4758 | </span> | |
| 4759 | </div> | |
| 4760 | </SelectItem> | |
| 4761 | ); | |
| 4762 | })} | |
| 4763 | </SelectPopup> | |
| 4764 | </Select> | |
| 4765 | ||
| 4766 | {activePlan || sidebarProposedPlan || planSidebarOpen ? ( | |
| 4767 | <> | |
| 4768 | <Separator | |
| 4769 | orientation="vertical" | |
| 4770 | className="mx-0.5 hidden h-4 sm:block" | |
| 4771 | /> | |
| 4772 | <Button | |
| 4773 | variant="ghost" | |
| 4774 | className={cn( | |
| 4775 | "shrink-0 whitespace-nowrap px-2 sm:px-3", | |
| 4776 | planSidebarOpen | |
| 4777 | ? "text-blue-400 hover:text-blue-300" | |
| 4778 | : "text-muted-foreground/70 hover:text-foreground/80", | |
| 4779 | )} | |
| 4780 | size="sm" | |
| 4781 | type="button" | |
| 4782 | onClick={togglePlanSidebar} | |
| 4783 | title={ | |
| 4784 | planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar" | |
| 4785 | } | |
| 4786 | > | |
| 4787 | <ListTodoIcon /> | |
| 4788 | <span className="sr-only sm:not-sr-only">Plan</span> | |
| 4789 | </Button> | |
| 4790 | </> | |
| 4791 | ) : null} | |
| 4792 | </> | |
| 4793 | )} | |
| 4794 | </div> | |
| 4795 | ||
| 4796 | {/* Right side: send / stop button */} | |
| 4797 | <div | |
| 4798 | ref={composerFooterActionsRef} | |
| 4799 | data-chat-composer-actions="right" | |
| 4800 | data-chat-composer-primary-actions-compact={ | |
| 4801 | isComposerPrimaryActionsCompact ? "true" : "false" | |
| 4802 | } | |
| 4803 | className="flex shrink-0 flex-nowrap items-center justify-end gap-2" | |
| 4804 | > | |
| 4805 | {activeContextWindow ? ( | |
| 4806 | <ContextWindowMeter usage={activeContextWindow} /> | |
| 4807 | ) : null} | |
| 4808 | {isPreparingWorktree ? ( | |
| 4809 | <span className="text-muted-foreground/70 text-xs"> | |
| 4810 | Preparing worktree... | |
| 4811 | </span> | |
| 4812 | ) : null} | |
| 4813 | <ComposerPrimaryActions | |
| 4814 | compact={isComposerPrimaryActionsCompact} | |
| 4815 | pendingAction={ | |
| 4816 | activePendingProgress | |
| 4817 | ? { | |
| 4818 | questionIndex: activePendingProgress.questionIndex, | |
| 4819 | isLastQuestion: activePendingProgress.isLastQuestion, | |
| 4820 | canAdvance: activePendingProgress.canAdvance, | |
| 4821 | isResponding: activePendingIsResponding, | |
| 4822 | isComplete: Boolean(activePendingResolvedAnswers), | |
| 4823 | } | |
| 4824 | : null | |
| 4825 | } | |
| 4826 | isRunning={phase === "running"} | |
| 4827 | showPlanFollowUpPrompt={ | |
| 4828 | pendingUserInputs.length === 0 && showPlanFollowUpPrompt | |
| 4829 | } | |
| 4830 | promptHasText={prompt.trim().length > 0} | |
| 4831 | isSendBusy={isSendBusy} | |
| 4832 | isConnecting={isConnecting} | |
| 4833 | isPreparingWorktree={isPreparingWorktree} | |
| 4834 | hasSendableContent={composerSendState.hasSendableContent} | |
| 4835 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} | |
| 4836 | onInterrupt={() => void onInterrupt()} | |
| 4837 | onImplementPlanInNewThread={() => void onImplementPlanInNewThread()} | |
| 4838 | /> | |
| 4839 | </div> | |
| 4840 | </div> | |
| 4841 | )} | |
| 4842 | </div> | |
| 4843 | </div> | |
| 4844 | </form> | |
| 3369 | <ChatComposer | |
| 3370 | ref={composerRef} | |
| 3371 | composerDraftTarget={composerDraftTarget} | |
| 3372 | environmentId={environmentId} | |
| 3373 | routeKind={routeKind} | |
| 3374 | routeThreadRef={routeThreadRef} | |
| 3375 | draftId={draftId} | |
| 3376 | activeThreadId={activeThreadId} | |
| 3377 | activeThreadEnvironmentId={activeThread?.environmentId} | |
| 3378 | activeThread={activeThread} | |
| 3379 | isServerThread={isServerThread} | |
| 3380 | isLocalDraftThread={isLocalDraftThread} | |
| 3381 | phase={phase} | |
| 3382 | isConnecting={isConnecting} | |
| 3383 | isSendBusy={isSendBusy} | |
| 3384 | isPreparingWorktree={isPreparingWorktree} | |
| 3385 | activePendingApproval={activePendingApproval} | |
| 3386 | pendingApprovals={pendingApprovals} | |
| 3387 | pendingUserInputs={pendingUserInputs} | |
| 3388 | activePendingProgress={activePendingProgress} | |
| 3389 | activePendingResolvedAnswers={activePendingResolvedAnswers} | |
| 3390 | activePendingIsResponding={activePendingIsResponding} | |
| 3391 | activePendingDraftAnswers={activePendingDraftAnswers} | |
| 3392 | activePendingQuestionIndex={activePendingQuestionIndex} | |
| 3393 | respondingRequestIds={respondingRequestIds} | |
| 3394 | showPlanFollowUpPrompt={showPlanFollowUpPrompt} | |
| 3395 | activeProposedPlan={activeProposedPlan} | |
| 3396 | activePlan={activePlan as { turnId?: TurnId } | null} | |
| 3397 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} | |
| 3398 | planSidebarOpen={planSidebarOpen} | |
| 3399 | runtimeMode={runtimeMode} | |
| 3400 | interactionMode={interactionMode} | |
| 3401 | lockedProvider={lockedProvider} | |
| 3402 | providerStatuses={providerStatuses as ServerProvider[]} | |
| 3403 | activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} | |
| 3404 | activeThreadModelSelection={activeThread?.modelSelection} | |
| 3405 | activeThreadActivities={activeThread?.activities} | |
| 3406 | resolvedTheme={resolvedTheme} | |
| 3407 | settings={settings} | |
| 3408 | gitCwd={gitCwd} | |
| 3409 | promptRef={promptRef} | |
| 3410 | composerImagesRef={composerImagesRef} | |
| 3411 | composerTerminalContextsRef={composerTerminalContextsRef} | |
| 3412 | shouldAutoScrollRef={shouldAutoScrollRef} | |
| 3413 | scheduleStickToBottom={scheduleStickToBottom} | |
| 3414 | onSend={onSend} | |
| 3415 | onInterrupt={onInterrupt} | |
| 3416 | onImplementPlanInNewThread={onImplementPlanInNewThread} | |
| 3417 | onRespondToApproval={onRespondToApproval} | |
| 3418 | onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} | |
| 3419 | onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} | |
| 3420 | onPreviousActivePendingUserInputQuestion={onPreviousActivePendingUserInputQuestion} | |
| 3421 | onChangeActivePendingUserInputCustomAnswer={ | |
| 3422 | onChangeActivePendingUserInputCustomAnswer | |
| 3423 | } | |
| 3424 | onProviderModelSelect={onProviderModelSelect} | |
| 3425 | toggleInteractionMode={toggleInteractionMode} | |
| 3426 | handleRuntimeModeChange={handleRuntimeModeChange} | |
| 3427 | handleInteractionModeChange={handleInteractionModeChange} | |
| 3428 | togglePlanSidebar={togglePlanSidebar} | |
| 3429 | focusComposer={focusComposer} | |
| 3430 | scheduleComposerFocus={scheduleComposerFocus} | |
| 3431 | setThreadError={setThreadError} | |
| 3432 | onExpandImage={onExpandTimelineImage} | |
| 3433 | /> | |
| 4845 | 3434 | </div> |
| 4846 | 3435 | |
| 4847 | 3436 | {isGitRepo && ( |
| ... | ... | @@ -4921,72 +3510,8 @@ export default function ChatView(props: ChatViewProps) { |
| 4921 | 3510 | /> |
| 4922 | 3511 | ))} |
| 4923 | 3512 | |
| 4924 | {expandedImage && expandedImageItem && ( | |
| 4925 | <div | |
| 4926 | className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]" | |
| 4927 | role="dialog" | |
| 4928 | aria-modal="true" | |
| 4929 | aria-label="Expanded image preview" | |
| 4930 | > | |
| 4931 | <button | |
| 4932 | type="button" | |
| 4933 | className="absolute inset-0 z-0 cursor-zoom-out" | |
| 4934 | aria-label="Close image preview" | |
| 4935 | onClick={closeExpandedImage} | |
| 4936 | /> | |
| 4937 | {expandedImage.images.length > 1 && ( | |
| 4938 | <Button | |
| 4939 | type="button" | |
| 4940 | size="icon" | |
| 4941 | variant="ghost" | |
| 4942 | className="absolute left-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6" | |
| 4943 | aria-label="Previous image" | |
| 4944 | onClick={() => { | |
| 4945 | navigateExpandedImage(-1); | |
| 4946 | }} | |
| 4947 | > | |
| 4948 | <ChevronLeftIcon className="size-5" /> | |
| 4949 | </Button> | |
| 4950 | )} | |
| 4951 | <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]"> | |
| 4952 | <Button | |
| 4953 | type="button" | |
| 4954 | size="icon-xs" | |
| 4955 | variant="ghost" | |
| 4956 | className="absolute right-2 top-2" | |
| 4957 | onClick={closeExpandedImage} | |
| 4958 | aria-label="Close image preview" | |
| 4959 | > | |
| 4960 | <XIcon /> | |
| 4961 | </Button> | |
| 4962 | <img | |
| 4963 | src={expandedImageItem.src} | |
| 4964 | alt={expandedImageItem.name} | |
| 4965 | className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl" | |
| 4966 | draggable={false} | |
| 4967 | /> | |
| 4968 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> | |
| 4969 | {expandedImageItem.name} | |
| 4970 | {expandedImage.images.length > 1 | |
| 4971 | ? ` (${expandedImage.index + 1}/${expandedImage.images.length})` | |
| 4972 | : ""} | |
| 4973 | </p> | |
| 4974 | </div> | |
| 4975 | {expandedImage.images.length > 1 && ( | |
| 4976 | <Button | |
| 4977 | type="button" | |
| 4978 | size="icon" | |
| 4979 | variant="ghost" | |
| 4980 | className="absolute right-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6" | |
| 4981 | aria-label="Next image" | |
| 4982 | onClick={() => { | |
| 4983 | navigateExpandedImage(1); | |
| 4984 | }} | |
| 4985 | > | |
| 4986 | <ChevronRightIcon className="size-5" /> | |
| 4987 | </Button> | |
| 4988 | )} | |
| 4989 | </div> | |
| 3513 | {expandedImage && ( | |
| 3514 | <ExpandedImageDialog preview={expandedImage} onClose={closeExpandedImage} /> | |
| 4990 | 3515 | )} |
| 4991 | 3516 | </div> |
| 4992 | 3517 | ); |
apps/web/src/components/Sidebar.tsx+55-23| ... | ... | @@ -79,7 +79,7 @@ import { |
| 79 | 79 | import { useGitStatus } from "../lib/gitStatusState"; |
| 80 | 80 | import { readLocalApi } from "../localApi"; |
| 81 | 81 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 82 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | |
| 82 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | |
| 83 | 83 | |
| 84 | 84 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 85 | 85 | import { |
| ... | ... | @@ -932,7 +932,7 @@ interface SidebarProjectItemProps { |
| 932 | 932 | isThreadListExpanded: boolean; |
| 933 | 933 | activeRouteThreadKey: string | null; |
| 934 | 934 | newThreadShortcutLabel: string | null; |
| 935 | handleNewThread: ReturnType<typeof useHandleNewThread>["handleNewThread"]; | |
| 935 | handleNewThread: ReturnType<typeof useNewThreadHandler>["handleNewThread"]; | |
| 936 | 936 | archiveThread: ReturnType<typeof useThreadActions>["archiveThread"]; |
| 937 | 937 | deleteThread: ReturnType<typeof useThreadActions>["deleteThread"]; |
| 938 | 938 | threadJumpLabelByKey: ReadonlyMap<string, string>; |
| ... | ... | @@ -2033,7 +2033,7 @@ interface SidebarProjectsContentProps { |
| 2033 | 2033 | handleProjectDragStart: (event: DragStartEvent) => void; |
| 2034 | 2034 | handleProjectDragEnd: (event: DragEndEvent) => void; |
| 2035 | 2035 | handleProjectDragCancel: (event: DragCancelEvent) => void; |
| 2036 | handleNewThread: ReturnType<typeof useHandleNewThread>["handleNewThread"]; | |
| 2036 | handleNewThread: ReturnType<typeof useNewThreadHandler>["handleNewThread"]; | |
| 2037 | 2037 | archiveThread: ReturnType<typeof useThreadActions>["archiveThread"]; |
| 2038 | 2038 | deleteThread: ReturnType<typeof useThreadActions>["deleteThread"]; |
| 2039 | 2039 | sortedProjects: readonly SidebarProjectSnapshot[]; |
| ... | ... | @@ -2338,7 +2338,7 @@ export default function Sidebar() { |
| 2338 | 2338 | const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); |
| 2339 | 2339 | const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); |
| 2340 | 2340 | const { updateSettings } = useUpdateSettings(); |
| 2341 | const { handleNewThread } = useHandleNewThread(); | |
| 2341 | const { handleNewThread } = useNewThreadHandler(); | |
| 2342 | 2342 | const { archiveThread, deleteThread } = useThreadActions(); |
| 2343 | 2343 | const routeThreadRef = useParams({ |
| 2344 | 2344 | strict: false, |
| ... | ... | @@ -2827,31 +2827,62 @@ export default function Sidebar() { |
| 2827 | 2827 | ); |
| 2828 | 2828 | const [threadJumpLabelByKey, setThreadJumpLabelByKey] = |
| 2829 | 2829 | useState<ReadonlyMap<string, string>>(EMPTY_THREAD_JUMP_LABELS); |
| 2830 | const threadJumpLabelsRef = useRef<ReadonlyMap<string, string>>(EMPTY_THREAD_JUMP_LABELS); | |
| 2831 | threadJumpLabelsRef.current = threadJumpLabelByKey; | |
| 2832 | const showThreadJumpHintsRef = useRef(showThreadJumpHints); | |
| 2833 | showThreadJumpHintsRef.current = showThreadJumpHints; | |
| 2830 | 2834 | const visibleThreadJumpLabelByKey = showThreadJumpHints |
| 2831 | 2835 | ? threadJumpLabelByKey |
| 2832 | 2836 | : EMPTY_THREAD_JUMP_LABELS; |
| 2833 | 2837 | const orderedSidebarThreadKeys = visibleSidebarThreadKeys; |
| 2834 | 2838 | |
| 2835 | 2839 | useEffect(() => { |
| 2840 | const clearThreadJumpHints = () => { | |
| 2841 | setThreadJumpLabelByKey((current) => | |
| 2842 | current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, | |
| 2843 | ); | |
| 2844 | updateThreadJumpHintsVisibility(false); | |
| 2845 | }; | |
| 2846 | const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => | |
| 2847 | !event.metaKey && | |
| 2848 | !event.ctrlKey && | |
| 2849 | !event.altKey && | |
| 2850 | !event.shiftKey && | |
| 2851 | event.key !== "Meta" && | |
| 2852 | event.key !== "Control" && | |
| 2853 | event.key !== "Alt" && | |
| 2854 | event.key !== "Shift" && | |
| 2855 | !showThreadJumpHintsRef.current && | |
| 2856 | threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; | |
| 2857 | ||
| 2836 | 2858 | const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { |
| 2859 | if (shouldIgnoreThreadJumpHintUpdate(event)) { | |
| 2860 | return; | |
| 2861 | } | |
| 2837 | 2862 | const shortcutContext = getCurrentSidebarShortcutContext(); |
| 2838 | 2863 | const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { |
| 2839 | 2864 | platform, |
| 2840 | 2865 | context: shortcutContext, |
| 2841 | 2866 | }); |
| 2842 | setThreadJumpLabelByKey((current) => { | |
| 2843 | if (!shouldShowHints) { | |
| 2844 | return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; | |
| 2867 | if (!shouldShowHints) { | |
| 2868 | if ( | |
| 2869 | showThreadJumpHintsRef.current || | |
| 2870 | threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS | |
| 2871 | ) { | |
| 2872 | clearThreadJumpHints(); | |
| 2845 | 2873 | } |
| 2846 | const nextLabelMap = buildThreadJumpLabelMap({ | |
| 2847 | keybindings, | |
| 2848 | platform, | |
| 2849 | terminalOpen: shortcutContext.terminalOpen, | |
| 2850 | threadJumpCommandByKey, | |
| 2874 | } else { | |
| 2875 | setThreadJumpLabelByKey((current) => { | |
| 2876 | const nextLabelMap = buildThreadJumpLabelMap({ | |
| 2877 | keybindings, | |
| 2878 | platform, | |
| 2879 | terminalOpen: shortcutContext.terminalOpen, | |
| 2880 | threadJumpCommandByKey, | |
| 2881 | }); | |
| 2882 | return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; | |
| 2851 | 2883 | }); |
| 2852 | return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; | |
| 2853 | }); | |
| 2854 | updateThreadJumpHintsVisibility(shouldShowHints); | |
| 2884 | updateThreadJumpHintsVisibility(true); | |
| 2885 | } | |
| 2855 | 2886 | |
| 2856 | 2887 | if (event.defaultPrevented || event.repeat) { |
| 2857 | 2888 | return; |
| ... | ... | @@ -2902,15 +2933,19 @@ export default function Sidebar() { |
| 2902 | 2933 | }; |
| 2903 | 2934 | |
| 2904 | 2935 | const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { |
| 2936 | if (shouldIgnoreThreadJumpHintUpdate(event)) { | |
| 2937 | return; | |
| 2938 | } | |
| 2905 | 2939 | const shortcutContext = getCurrentSidebarShortcutContext(); |
| 2906 | 2940 | const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { |
| 2907 | 2941 | platform, |
| 2908 | 2942 | context: shortcutContext, |
| 2909 | 2943 | }); |
| 2944 | if (!shouldShowHints) { | |
| 2945 | clearThreadJumpHints(); | |
| 2946 | return; | |
| 2947 | } | |
| 2910 | 2948 | setThreadJumpLabelByKey((current) => { |
| 2911 | if (!shouldShowHints) { | |
| 2912 | return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; | |
| 2913 | } | |
| 2914 | 2949 | const nextLabelMap = buildThreadJumpLabelMap({ |
| 2915 | 2950 | keybindings, |
| 2916 | 2951 | platform, |
| ... | ... | @@ -2919,14 +2954,11 @@ export default function Sidebar() { |
| 2919 | 2954 | }); |
| 2920 | 2955 | return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; |
| 2921 | 2956 | }); |
| 2922 | updateThreadJumpHintsVisibility(shouldShowHints); | |
| 2957 | updateThreadJumpHintsVisibility(true); | |
| 2923 | 2958 | }; |
| 2924 | 2959 | |
| 2925 | 2960 | const onWindowBlur = () => { |
| 2926 | setThreadJumpLabelByKey((current) => | |
| 2927 | current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, | |
| 2928 | ); | |
| 2929 | updateThreadJumpHintsVisibility(false); | |
| 2961 | clearThreadJumpHints(); | |
| 2930 | 2962 | }; |
| 2931 | 2963 | |
| 2932 | 2964 | window.addEventListener("keydown", onWindowKeyDown); |
apps/web/src/components/chat/ChatComposer.tsx created+1899| ... | ... | @@ -0,0 +1,1899 @@ |
| 1 | import type { | |
| 2 | ApprovalRequestId, | |
| 3 | EnvironmentId, | |
| 4 | ModelSelection, | |
| 5 | ProjectEntry, | |
| 6 | ProviderApprovalDecision, | |
| 7 | ProviderInteractionMode, | |
| 8 | ProviderKind, | |
| 9 | RuntimeMode, | |
| 10 | ScopedThreadRef, | |
| 11 | ServerProvider, | |
| 12 | ThreadId, | |
| 13 | TurnId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { | |
| 16 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 17 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { normalizeModelSlug } from "@t3tools/shared/model"; | |
| 20 | import { | |
| 21 | forwardRef, | |
| 22 | memo, | |
| 23 | useCallback, | |
| 24 | useEffect, | |
| 25 | useImperativeHandle, | |
| 26 | useLayoutEffect, | |
| 27 | useMemo, | |
| 28 | useRef, | |
| 29 | useState, | |
| 30 | } from "react"; | |
| 31 | import { useQuery } from "@tanstack/react-query"; | |
| 32 | import { useDebouncedValue } from "@tanstack/react-pacer"; | |
| 33 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | |
| 34 | import { | |
| 35 | clampCollapsedComposerCursor, | |
| 36 | type ComposerTrigger, | |
| 37 | collapseExpandedComposerCursor, | |
| 38 | detectComposerTrigger, | |
| 39 | expandCollapsedComposerCursor, | |
| 40 | replaceTextRange, | |
| 41 | } from "../../composer-logic"; | |
| 42 | import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; | |
| 43 | import { | |
| 44 | type ComposerImageAttachment, | |
| 45 | type DraftId, | |
| 46 | type PersistedComposerImageAttachment, | |
| 47 | useComposerDraftStore, | |
| 48 | useComposerThreadDraft, | |
| 49 | useEffectiveComposerModelState, | |
| 50 | } from "../../composerDraftStore"; | |
| 51 | import { | |
| 52 | type TerminalContextDraft, | |
| 53 | type TerminalContextSelection, | |
| 54 | insertInlineTerminalContextPlaceholder, | |
| 55 | removeInlineTerminalContextPlaceholder, | |
| 56 | } from "../../lib/terminalContext"; | |
| 57 | import { | |
| 58 | resolveComposerFooterContentWidth, | |
| 59 | shouldForceCompactComposerFooterForFit, | |
| 60 | shouldUseCompactComposerPrimaryActions, | |
| 61 | shouldUseCompactComposerFooter, | |
| 62 | } from "../composerFooterLayout"; | |
| 63 | import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; | |
| 64 | import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./ProviderModelPicker"; | |
| 65 | import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; | |
| 66 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; | |
| 67 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; | |
| 68 | import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; | |
| 69 | import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; | |
| 70 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; | |
| 71 | import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; | |
| 72 | import { | |
| 73 | getComposerProviderState, | |
| 74 | renderProviderTraitsMenuContent, | |
| 75 | renderProviderTraitsPicker, | |
| 76 | } from "./composerProviderRegistry"; | |
| 77 | import { ContextWindowMeter } from "./ContextWindowMeter"; | |
| 78 | import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 79 | import { basenameOfPath } from "../../vscode-icons"; | |
| 80 | import { cn, randomUUID } from "~/lib/utils"; | |
| 81 | import { Separator } from "../ui/separator"; | |
| 82 | import { Button } from "../ui/button"; | |
| 83 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | |
| 84 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 85 | import { toastManager } from "../ui/toast"; | |
| 86 | import { | |
| 87 | BotIcon, | |
| 88 | CircleAlertIcon, | |
| 89 | ListTodoIcon, | |
| 90 | type LucideIcon, | |
| 91 | LockIcon, | |
| 92 | LockOpenIcon, | |
| 93 | PenLineIcon, | |
| 94 | XIcon, | |
| 95 | } from "lucide-react"; | |
| 96 | import { proposedPlanTitle } from "../../proposedPlan"; | |
| 97 | import { resolveSelectableProvider, getProviderModels } from "../../providerModels"; | |
| 98 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; | |
| 99 | import type { SessionPhase, Thread } from "../../types"; | |
| 100 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; | |
| 101 | import type { PendingApproval, PendingUserInput } from "../../session-logic"; | |
| 102 | import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; | |
| 103 | ||
| 104 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; | |
| 105 | ||
| 106 | const runtimeModeConfig: Record< | |
| 107 | RuntimeMode, | |
| 108 | { label: string; description: string; icon: LucideIcon } | |
| 109 | > = { | |
| 110 | "approval-required": { | |
| 111 | label: "Supervised", | |
| 112 | description: "Ask before commands and file changes.", | |
| 113 | icon: LockIcon, | |
| 114 | }, | |
| 115 | "auto-accept-edits": { | |
| 116 | label: "Auto-accept edits", | |
| 117 | description: "Auto-approve edits, ask before other actions.", | |
| 118 | icon: PenLineIcon, | |
| 119 | }, | |
| 120 | "full-access": { | |
| 121 | label: "Full access", | |
| 122 | description: "Allow commands and edits without prompts.", | |
| 123 | icon: LockOpenIcon, | |
| 124 | }, | |
| 125 | }; | |
| 126 | ||
| 127 | const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; | |
| 128 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; | |
| 129 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; | |
| 130 | ||
| 131 | const extendReplacementRangeForTrailingSpace = ( | |
| 132 | text: string, | |
| 133 | rangeEnd: number, | |
| 134 | replacement: string, | |
| 135 | ): number => { | |
| 136 | if (!replacement.endsWith(" ")) { | |
| 137 | return rangeEnd; | |
| 138 | } | |
| 139 | return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; | |
| 140 | }; | |
| 141 | ||
| 142 | const syncTerminalContextsByIds = ( | |
| 143 | contexts: ReadonlyArray<TerminalContextDraft>, | |
| 144 | ids: ReadonlyArray<string>, | |
| 145 | ): TerminalContextDraft[] => { | |
| 146 | const contextsById = new Map(contexts.map((context) => [context.id, context])); | |
| 147 | return ids.flatMap((id) => { | |
| 148 | const context = contextsById.get(id); | |
| 149 | return context ? [context] : []; | |
| 150 | }); | |
| 151 | }; | |
| 152 | ||
| 153 | const terminalContextIdListsEqual = ( | |
| 154 | contexts: ReadonlyArray<TerminalContextDraft>, | |
| 155 | ids: ReadonlyArray<string>, | |
| 156 | ): boolean => | |
| 157 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); | |
| 158 | ||
| 159 | const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { | |
| 160 | interactionMode: ProviderInteractionMode; | |
| 161 | runtimeMode: RuntimeMode; | |
| 162 | showPlanToggle: boolean; | |
| 163 | planSidebarOpen: boolean; | |
| 164 | onToggleInteractionMode: () => void; | |
| 165 | onRuntimeModeChange: (mode: RuntimeMode) => void; | |
| 166 | onTogglePlanSidebar: () => void; | |
| 167 | }) { | |
| 168 | const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; | |
| 169 | const RuntimeModeIcon = runtimeModeOption.icon; | |
| 170 | ||
| 171 | return ( | |
| 172 | <> | |
| 173 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 174 | ||
| 175 | <Button | |
| 176 | variant="ghost" | |
| 177 | className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | |
| 178 | size="sm" | |
| 179 | type="button" | |
| 180 | onClick={props.onToggleInteractionMode} | |
| 181 | title={ | |
| 182 | props.interactionMode === "plan" | |
| 183 | ? "Plan mode — click to return to normal build mode" | |
| 184 | : "Default mode — click to enter plan mode" | |
| 185 | } | |
| 186 | > | |
| 187 | <BotIcon /> | |
| 188 | <span className="sr-only sm:not-sr-only"> | |
| 189 | {props.interactionMode === "plan" ? "Plan" : "Build"} | |
| 190 | </span> | |
| 191 | </Button> | |
| 192 | ||
| 193 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 194 | ||
| 195 | <Select | |
| 196 | value={props.runtimeMode} | |
| 197 | onValueChange={(value) => props.onRuntimeModeChange(value!)} | |
| 198 | > | |
| 199 | <SelectTrigger | |
| 200 | variant="ghost" | |
| 201 | size="sm" | |
| 202 | aria-label="Runtime mode" | |
| 203 | title={runtimeModeOption.description} | |
| 204 | > | |
| 205 | <RuntimeModeIcon className="size-4" /> | |
| 206 | <SelectValue>{runtimeModeOption.label}</SelectValue> | |
| 207 | </SelectTrigger> | |
| 208 | <SelectPopup alignItemWithTrigger={false}> | |
| 209 | {runtimeModeOptions.map((mode) => { | |
| 210 | const option = runtimeModeConfig[mode]; | |
| 211 | const OptionIcon = option.icon; | |
| 212 | return ( | |
| 213 | <SelectItem key={mode} value={mode} className="min-w-64 py-2"> | |
| 214 | <div className="grid min-w-0 gap-0.5"> | |
| 215 | <span className="inline-flex items-center gap-1.5 font-medium text-foreground"> | |
| 216 | <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 217 | {option.label} | |
| 218 | </span> | |
| 219 | <span className="text-muted-foreground text-xs leading-4"> | |
| 220 | {option.description} | |
| 221 | </span> | |
| 222 | </div> | |
| 223 | </SelectItem> | |
| 224 | ); | |
| 225 | })} | |
| 226 | </SelectPopup> | |
| 227 | </Select> | |
| 228 | ||
| 229 | {props.showPlanToggle ? ( | |
| 230 | <> | |
| 231 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 232 | <Button | |
| 233 | variant="ghost" | |
| 234 | className={cn( | |
| 235 | "shrink-0 whitespace-nowrap px-2 sm:px-3", | |
| 236 | props.planSidebarOpen | |
| 237 | ? "text-blue-400 hover:text-blue-300" | |
| 238 | : "text-muted-foreground/70 hover:text-foreground/80", | |
| 239 | )} | |
| 240 | size="sm" | |
| 241 | type="button" | |
| 242 | onClick={props.onTogglePlanSidebar} | |
| 243 | title={props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} | |
| 244 | > | |
| 245 | <ListTodoIcon /> | |
| 246 | <span className="sr-only sm:not-sr-only">Plan</span> | |
| 247 | </Button> | |
| 248 | </> | |
| 249 | ) : null} | |
| 250 | </> | |
| 251 | ); | |
| 252 | }); | |
| 253 | ||
| 254 | const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { | |
| 255 | compact: boolean; | |
| 256 | activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>; | |
| 257 | isPreparingWorktree: boolean; | |
| 258 | pendingAction: { | |
| 259 | questionIndex: number; | |
| 260 | isLastQuestion: boolean; | |
| 261 | canAdvance: boolean; | |
| 262 | isResponding: boolean; | |
| 263 | isComplete: boolean; | |
| 264 | } | null; | |
| 265 | isRunning: boolean; | |
| 266 | showPlanFollowUpPrompt: boolean; | |
| 267 | promptHasText: boolean; | |
| 268 | isSendBusy: boolean; | |
| 269 | isConnecting: boolean; | |
| 270 | hasSendableContent: boolean; | |
| 271 | onPreviousPendingQuestion: () => void; | |
| 272 | onInterrupt: () => void; | |
| 273 | onImplementPlanInNewThread: () => void; | |
| 274 | }) { | |
| 275 | return ( | |
| 276 | <> | |
| 277 | {props.activeContextWindow ? <ContextWindowMeter usage={props.activeContextWindow} /> : null} | |
| 278 | {props.isPreparingWorktree ? ( | |
| 279 | <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span> | |
| 280 | ) : null} | |
| 281 | <ComposerPrimaryActions | |
| 282 | compact={props.compact} | |
| 283 | pendingAction={props.pendingAction} | |
| 284 | isRunning={props.isRunning} | |
| 285 | showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} | |
| 286 | promptHasText={props.promptHasText} | |
| 287 | isSendBusy={props.isSendBusy} | |
| 288 | isConnecting={props.isConnecting} | |
| 289 | isPreparingWorktree={props.isPreparingWorktree} | |
| 290 | hasSendableContent={props.hasSendableContent} | |
| 291 | onPreviousPendingQuestion={props.onPreviousPendingQuestion} | |
| 292 | onInterrupt={props.onInterrupt} | |
| 293 | onImplementPlanInNewThread={props.onImplementPlanInNewThread} | |
| 294 | /> | |
| 295 | </> | |
| 296 | ); | |
| 297 | }); | |
| 298 | ||
| 299 | // -------------------------------------------------------------------------- | |
| 300 | // Handle exposed to ChatView | |
| 301 | // -------------------------------------------------------------------------- | |
| 302 | ||
| 303 | export interface ChatComposerHandle { | |
| 304 | focusAtEnd: () => void; | |
| 305 | focusAt: (cursor: number) => void; | |
| 306 | readSnapshot: () => { | |
| 307 | value: string; | |
| 308 | cursor: number; | |
| 309 | expandedCursor: number; | |
| 310 | terminalContextIds: string[]; | |
| 311 | }; | |
| 312 | /** Reset composer cursor/trigger/highlight after external prompt mutations (e.g. onSend). */ | |
| 313 | resetCursorState: (options?: { | |
| 314 | cursor?: number; | |
| 315 | prompt?: string; | |
| 316 | detectTrigger?: boolean; | |
| 317 | }) => void; | |
| 318 | /** Insert a terminal context from the terminal drawer. */ | |
| 319 | addTerminalContext: (selection: TerminalContextSelection) => void; | |
| 320 | /** Get the current prompt/effort/model state for use in send. */ | |
| 321 | getSendContext: () => { | |
| 322 | prompt: string; | |
| 323 | images: ComposerImageAttachment[]; | |
| 324 | terminalContexts: TerminalContextDraft[]; | |
| 325 | selectedPromptEffort: string | null; | |
| 326 | selectedModelOptionsForDispatch: unknown; | |
| 327 | selectedModelSelection: ModelSelection; | |
| 328 | selectedProvider: ProviderKind; | |
| 329 | selectedModel: string; | |
| 330 | selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>; | |
| 331 | }; | |
| 332 | } | |
| 333 | ||
| 334 | // -------------------------------------------------------------------------- | |
| 335 | // Props | |
| 336 | // -------------------------------------------------------------------------- | |
| 337 | ||
| 338 | export interface ChatComposerProps { | |
| 339 | composerDraftTarget: ScopedThreadRef | DraftId; | |
| 340 | environmentId: EnvironmentId; | |
| 341 | routeKind: "server" | "draft"; | |
| 342 | routeThreadRef: ScopedThreadRef; | |
| 343 | draftId: DraftId | null; | |
| 344 | ||
| 345 | // Thread context | |
| 346 | activeThreadId: ThreadId | null; | |
| 347 | activeThreadEnvironmentId: EnvironmentId | undefined; | |
| 348 | activeThread: Thread | undefined; | |
| 349 | isServerThread: boolean; | |
| 350 | isLocalDraftThread: boolean; | |
| 351 | ||
| 352 | // Session phase | |
| 353 | phase: SessionPhase; | |
| 354 | isConnecting: boolean; | |
| 355 | isSendBusy: boolean; | |
| 356 | isPreparingWorktree: boolean; | |
| 357 | ||
| 358 | // Pending approvals / inputs | |
| 359 | activePendingApproval: PendingApproval | null; | |
| 360 | pendingApprovals: PendingApproval[]; | |
| 361 | pendingUserInputs: PendingUserInput[]; | |
| 362 | activePendingProgress: { | |
| 363 | questionIndex: number; | |
| 364 | isLastQuestion: boolean; | |
| 365 | canAdvance: boolean; | |
| 366 | customAnswer: string; | |
| 367 | activeQuestion: { id: string } | null; | |
| 368 | } | null; | |
| 369 | activePendingResolvedAnswers: Record<string, unknown> | null; | |
| 370 | activePendingIsResponding: boolean; | |
| 371 | activePendingDraftAnswers: Record<string, PendingUserInputDraftAnswer>; | |
| 372 | activePendingQuestionIndex: number; | |
| 373 | respondingRequestIds: ApprovalRequestId[]; | |
| 374 | ||
| 375 | // Plan | |
| 376 | showPlanFollowUpPrompt: boolean; | |
| 377 | activeProposedPlan: Thread["proposedPlans"][number] | null; | |
| 378 | activePlan: { turnId?: TurnId } | null; | |
| 379 | sidebarProposedPlan: { turnId?: TurnId } | null; | |
| 380 | planSidebarOpen: boolean; | |
| 381 | ||
| 382 | // Mode | |
| 383 | runtimeMode: RuntimeMode; | |
| 384 | interactionMode: ProviderInteractionMode; | |
| 385 | ||
| 386 | // Provider / model | |
| 387 | lockedProvider: ProviderKind | null; | |
| 388 | providerStatuses: ServerProvider[]; | |
| 389 | activeProjectDefaultModelSelection: ModelSelection | null | undefined; | |
| 390 | activeThreadModelSelection: ModelSelection | null | undefined; | |
| 391 | ||
| 392 | // Context window | |
| 393 | activeThreadActivities: Thread["activities"] | undefined; | |
| 394 | ||
| 395 | // Misc | |
| 396 | resolvedTheme: "light" | "dark"; | |
| 397 | settings: UnifiedSettings; | |
| 398 | gitCwd: string | null; | |
| 399 | ||
| 400 | // Refs the parent needs kept in sync | |
| 401 | promptRef: React.MutableRefObject<string>; | |
| 402 | composerImagesRef: React.MutableRefObject<ComposerImageAttachment[]>; | |
| 403 | composerTerminalContextsRef: React.MutableRefObject<TerminalContextDraft[]>; | |
| 404 | ||
| 405 | // Scroll | |
| 406 | shouldAutoScrollRef: React.MutableRefObject<boolean>; | |
| 407 | scheduleStickToBottom: () => void; | |
| 408 | ||
| 409 | // Callbacks | |
| 410 | onSend: (e?: { preventDefault: () => void }) => void; | |
| 411 | onInterrupt: () => void; | |
| 412 | onImplementPlanInNewThread: () => void; | |
| 413 | onRespondToApproval: ( | |
| 414 | requestId: ApprovalRequestId, | |
| 415 | decision: ProviderApprovalDecision, | |
| 416 | ) => Promise<void>; | |
| 417 | onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; | |
| 418 | onAdvanceActivePendingUserInput: () => void; | |
| 419 | onPreviousActivePendingUserInputQuestion: () => void; | |
| 420 | onChangeActivePendingUserInputCustomAnswer: ( | |
| 421 | questionId: string, | |
| 422 | value: string, | |
| 423 | nextCursor: number, | |
| 424 | expandedCursor: number, | |
| 425 | cursorAdjacentToMention: boolean, | |
| 426 | ) => void; | |
| 427 | ||
| 428 | onProviderModelSelect: (provider: ProviderKind, model: string) => void; | |
| 429 | toggleInteractionMode: () => void; | |
| 430 | handleRuntimeModeChange: (mode: RuntimeMode) => void; | |
| 431 | handleInteractionModeChange: (mode: ProviderInteractionMode) => void; | |
| 432 | togglePlanSidebar: () => void; | |
| 433 | ||
| 434 | focusComposer: () => void; | |
| 435 | scheduleComposerFocus: () => void; | |
| 436 | setThreadError: (threadId: ThreadId | null, error: string | null) => void; | |
| 437 | onExpandImage: (preview: ExpandedImagePreview) => void; | |
| 438 | } | |
| 439 | ||
| 440 | // -------------------------------------------------------------------------- | |
| 441 | // Component | |
| 442 | // -------------------------------------------------------------------------- | |
| 443 | ||
| 444 | export const ChatComposer = memo( | |
| 445 | forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer(props, ref) { | |
| 446 | const { | |
| 447 | composerDraftTarget, | |
| 448 | environmentId, | |
| 449 | routeKind, | |
| 450 | routeThreadRef, | |
| 451 | draftId, | |
| 452 | activeThreadId, | |
| 453 | activeThreadEnvironmentId: _activeThreadEnvironmentId, | |
| 454 | activeThread, | |
| 455 | isServerThread: _isServerThread, | |
| 456 | isLocalDraftThread: _isLocalDraftThread, | |
| 457 | phase, | |
| 458 | isConnecting, | |
| 459 | isSendBusy, | |
| 460 | isPreparingWorktree, | |
| 461 | activePendingApproval, | |
| 462 | pendingApprovals, | |
| 463 | pendingUserInputs, | |
| 464 | activePendingProgress, | |
| 465 | activePendingResolvedAnswers, | |
| 466 | activePendingIsResponding, | |
| 467 | activePendingDraftAnswers, | |
| 468 | activePendingQuestionIndex, | |
| 469 | respondingRequestIds, | |
| 470 | showPlanFollowUpPrompt, | |
| 471 | activeProposedPlan, | |
| 472 | activePlan, | |
| 473 | sidebarProposedPlan, | |
| 474 | planSidebarOpen, | |
| 475 | runtimeMode, | |
| 476 | interactionMode, | |
| 477 | lockedProvider, | |
| 478 | providerStatuses, | |
| 479 | activeProjectDefaultModelSelection, | |
| 480 | activeThreadModelSelection, | |
| 481 | activeThreadActivities, | |
| 482 | resolvedTheme, | |
| 483 | settings, | |
| 484 | gitCwd, | |
| 485 | promptRef, | |
| 486 | composerImagesRef, | |
| 487 | composerTerminalContextsRef, | |
| 488 | shouldAutoScrollRef, | |
| 489 | scheduleStickToBottom, | |
| 490 | onSend, | |
| 491 | onInterrupt, | |
| 492 | onImplementPlanInNewThread, | |
| 493 | onRespondToApproval, | |
| 494 | onSelectActivePendingUserInputOption, | |
| 495 | onAdvanceActivePendingUserInput, | |
| 496 | onPreviousActivePendingUserInputQuestion, | |
| 497 | onChangeActivePendingUserInputCustomAnswer, | |
| 498 | onProviderModelSelect, | |
| 499 | toggleInteractionMode, | |
| 500 | handleRuntimeModeChange, | |
| 501 | handleInteractionModeChange, | |
| 502 | togglePlanSidebar, | |
| 503 | focusComposer, | |
| 504 | scheduleComposerFocus, | |
| 505 | setThreadError, | |
| 506 | onExpandImage, | |
| 507 | } = props; | |
| 508 | ||
| 509 | // ------------------------------------------------------------------ | |
| 510 | // Store subscriptions (prompt / images / terminal contexts) | |
| 511 | // ------------------------------------------------------------------ | |
| 512 | const composerDraft = useComposerThreadDraft(composerDraftTarget); | |
| 513 | const prompt = composerDraft.prompt; | |
| 514 | const composerImages = composerDraft.images; | |
| 515 | const composerTerminalContexts = composerDraft.terminalContexts; | |
| 516 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; | |
| 517 | ||
| 518 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); | |
| 519 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); | |
| 520 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); | |
| 521 | const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); | |
| 522 | const insertComposerDraftTerminalContext = useComposerDraftStore( | |
| 523 | (store) => store.insertTerminalContext, | |
| 524 | ); | |
| 525 | const removeComposerDraftTerminalContext = useComposerDraftStore( | |
| 526 | (store) => store.removeTerminalContext, | |
| 527 | ); | |
| 528 | const setComposerDraftTerminalContexts = useComposerDraftStore( | |
| 529 | (store) => store.setTerminalContexts, | |
| 530 | ); | |
| 531 | const clearComposerDraftPersistedAttachments = useComposerDraftStore( | |
| 532 | (store) => store.clearPersistedAttachments, | |
| 533 | ); | |
| 534 | const syncComposerDraftPersistedAttachments = useComposerDraftStore( | |
| 535 | (store) => store.syncPersistedAttachments, | |
| 536 | ); | |
| 537 | const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); | |
| 538 | ||
| 539 | // ------------------------------------------------------------------ | |
| 540 | // Model state | |
| 541 | // ------------------------------------------------------------------ | |
| 542 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; | |
| 543 | const threadProvider = | |
| 544 | activeThreadModelSelection?.provider ?? activeProjectDefaultModelSelection?.provider ?? null; | |
| 545 | ||
| 546 | const unlockedSelectedProvider = resolveSelectableProvider( | |
| 547 | providerStatuses, | |
| 548 | selectedProviderByThreadId ?? threadProvider ?? "codex", | |
| 549 | ); | |
| 550 | const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; | |
| 551 | ||
| 552 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ | |
| 553 | threadRef: composerDraftTarget, | |
| 554 | providers: providerStatuses, | |
| 555 | selectedProvider, | |
| 556 | threadModelSelection: activeThreadModelSelection, | |
| 557 | projectModelSelection: activeProjectDefaultModelSelection, | |
| 558 | settings, | |
| 559 | }); | |
| 560 | ||
| 561 | const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); | |
| 562 | ||
| 563 | const composerProviderState = useMemo( | |
| 564 | () => | |
| 565 | getComposerProviderState({ | |
| 566 | provider: selectedProvider, | |
| 567 | model: selectedModel, | |
| 568 | models: selectedProviderModels, | |
| 569 | prompt, | |
| 570 | modelOptions: composerModelOptions, | |
| 571 | }), | |
| 572 | [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], | |
| 573 | ); | |
| 574 | ||
| 575 | const selectedPromptEffort = composerProviderState.promptEffort; | |
| 576 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; | |
| 577 | const selectedModelSelection = useMemo<ModelSelection>( | |
| 578 | () => ({ | |
| 579 | provider: selectedProvider, | |
| 580 | model: selectedModel, | |
| 581 | ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), | |
| 582 | }), | |
| 583 | [selectedModel, selectedModelOptionsForDispatch, selectedProvider], | |
| 584 | ); | |
| 585 | const selectedModelForPicker = selectedModel; | |
| 586 | const modelOptionsByProvider = useMemo< | |
| 587 | Record<ProviderKind, ReadonlyArray<ServerProvider["models"][number]>> | |
| 588 | >( | |
| 589 | () => ({ | |
| 590 | codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], | |
| 591 | claudeAgent: | |
| 592 | providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], | |
| 593 | }), | |
| 594 | [providerStatuses], | |
| 595 | ); | |
| 596 | const selectedModelForPickerWithCustomFallback = useMemo(() => { | |
| 597 | const currentOptions = modelOptionsByProvider[selectedProvider]; | |
| 598 | return currentOptions.some((option) => option.slug === selectedModelForPicker) | |
| 599 | ? selectedModelForPicker | |
| 600 | : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); | |
| 601 | }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); | |
| 602 | const searchableModelOptions = useMemo( | |
| 603 | () => | |
| 604 | AVAILABLE_PROVIDER_OPTIONS.filter( | |
| 605 | (option) => lockedProvider === null || option.value === lockedProvider, | |
| 606 | ).flatMap((option) => | |
| 607 | modelOptionsByProvider[option.value].map(({ slug, name }) => ({ | |
| 608 | provider: option.value, | |
| 609 | providerLabel: option.label, | |
| 610 | slug, | |
| 611 | name, | |
| 612 | searchSlug: slug.toLowerCase(), | |
| 613 | searchName: name.toLowerCase(), | |
| 614 | searchProvider: option.label.toLowerCase(), | |
| 615 | })), | |
| 616 | ), | |
| 617 | [lockedProvider, modelOptionsByProvider], | |
| 618 | ); | |
| 619 | ||
| 620 | // ------------------------------------------------------------------ | |
| 621 | // Context window | |
| 622 | // ------------------------------------------------------------------ | |
| 623 | const activeContextWindow = useMemo( | |
| 624 | () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), | |
| 625 | [activeThreadActivities], | |
| 626 | ); | |
| 627 | ||
| 628 | // ------------------------------------------------------------------ | |
| 629 | // Composer-local state | |
| 630 | // ------------------------------------------------------------------ | |
| 631 | const [composerCursor, setComposerCursor] = useState(() => | |
| 632 | collapseExpandedComposerCursor(prompt, prompt.length), | |
| 633 | ); | |
| 634 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => | |
| 635 | detectComposerTrigger(prompt, prompt.length), | |
| 636 | ); | |
| 637 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | |
| 638 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); | |
| 639 | const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); | |
| 640 | const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); | |
| 641 | ||
| 642 | // ------------------------------------------------------------------ | |
| 643 | // Refs | |
| 644 | // ------------------------------------------------------------------ | |
| 645 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); | |
| 646 | const composerFormRef = useRef<HTMLFormElement>(null); | |
| 647 | const composerFormHeightRef = useRef(0); | |
| 648 | const composerFooterRef = useRef<HTMLDivElement>(null); | |
| 649 | const composerFooterLeadingRef = useRef<HTMLDivElement>(null); | |
| 650 | const composerFooterActionsRef = useRef<HTMLDivElement>(null); | |
| 651 | const composerSelectLockRef = useRef(false); | |
| 652 | const composerMenuOpenRef = useRef(false); | |
| 653 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); | |
| 654 | const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null); | |
| 655 | const dragDepthRef = useRef(0); | |
| 656 | ||
| 657 | // ------------------------------------------------------------------ | |
| 658 | // Derived: composer send state | |
| 659 | // ------------------------------------------------------------------ | |
| 660 | const composerSendState = useMemo( | |
| 661 | () => | |
| 662 | deriveComposerSendState({ | |
| 663 | prompt, | |
| 664 | imageCount: composerImages.length, | |
| 665 | terminalContexts: composerTerminalContexts, | |
| 666 | }), | |
| 667 | [composerImages.length, composerTerminalContexts, prompt], | |
| 668 | ); | |
| 669 | ||
| 670 | // ------------------------------------------------------------------ | |
| 671 | // Derived: composer trigger / menu | |
| 672 | // ------------------------------------------------------------------ | |
| 673 | const composerTriggerKind = composerTrigger?.kind ?? null; | |
| 674 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; | |
| 675 | const isPathTrigger = composerTriggerKind === "path"; | |
| 676 | const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( | |
| 677 | pathTriggerQuery, | |
| 678 | { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, | |
| 679 | (debouncerState) => ({ isPending: debouncerState.isPending }), | |
| 680 | ); | |
| 681 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; | |
| 682 | const workspaceEntriesQuery = useQuery( | |
| 683 | projectSearchEntriesQueryOptions({ | |
| 684 | environmentId, | |
| 685 | cwd: gitCwd, | |
| 686 | query: effectivePathQuery, | |
| 687 | enabled: isPathTrigger, | |
| 688 | limit: 80, | |
| 689 | }), | |
| 690 | ); | |
| 691 | const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; | |
| 692 | ||
| 693 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { | |
| 694 | if (!composerTrigger) return []; | |
| 695 | if (composerTrigger.kind === "path") { | |
| 696 | return workspaceEntries.map((entry) => ({ | |
| 697 | id: `path:${entry.kind}:${entry.path}`, | |
| 698 | type: "path", | |
| 699 | path: entry.path, | |
| 700 | pathKind: entry.kind, | |
| 701 | label: basenameOfPath(entry.path), | |
| 702 | description: entry.parentPath ?? "", | |
| 703 | })); | |
| 704 | } | |
| 705 | if (composerTrigger.kind === "slash-command") { | |
| 706 | const slashCommandItems = [ | |
| 707 | { | |
| 708 | id: "slash:model", | |
| 709 | type: "slash-command", | |
| 710 | command: "model", | |
| 711 | label: "/model", | |
| 712 | description: "Switch response model for this thread", | |
| 713 | }, | |
| 714 | { | |
| 715 | id: "slash:plan", | |
| 716 | type: "slash-command", | |
| 717 | command: "plan", | |
| 718 | label: "/plan", | |
| 719 | description: "Switch this thread into plan mode", | |
| 720 | }, | |
| 721 | { | |
| 722 | id: "slash:default", | |
| 723 | type: "slash-command", | |
| 724 | command: "default", | |
| 725 | label: "/default", | |
| 726 | description: "Switch this thread back to normal build mode", | |
| 727 | }, | |
| 728 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; | |
| 729 | const query = composerTrigger.query.trim().toLowerCase(); | |
| 730 | if (!query) { | |
| 731 | return [...slashCommandItems]; | |
| 732 | } | |
| 733 | return slashCommandItems.filter( | |
| 734 | (item) => item.command.includes(query) || item.label.slice(1).includes(query), | |
| 735 | ); | |
| 736 | } | |
| 737 | return searchableModelOptions | |
| 738 | .filter(({ searchSlug, searchName, searchProvider }) => { | |
| 739 | const query = composerTrigger.query.trim().toLowerCase(); | |
| 740 | if (!query) return true; | |
| 741 | return ( | |
| 742 | searchSlug.includes(query) || | |
| 743 | searchName.includes(query) || | |
| 744 | searchProvider.includes(query) | |
| 745 | ); | |
| 746 | }) | |
| 747 | .map(({ provider, providerLabel, slug, name }) => ({ | |
| 748 | id: `model:${provider}:${slug}`, | |
| 749 | type: "model", | |
| 750 | provider, | |
| 751 | model: slug, | |
| 752 | label: name, | |
| 753 | description: `${providerLabel} · ${slug}`, | |
| 754 | })); | |
| 755 | }, [composerTrigger, searchableModelOptions, workspaceEntries]); | |
| 756 | ||
| 757 | const composerMenuOpen = Boolean(composerTrigger); | |
| 758 | const activeComposerMenuItem = useMemo( | |
| 759 | () => | |
| 760 | composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? | |
| 761 | composerMenuItems[0] ?? | |
| 762 | null, | |
| 763 | [composerHighlightedItemId, composerMenuItems], | |
| 764 | ); | |
| 765 | ||
| 766 | composerMenuOpenRef.current = composerMenuOpen; | |
| 767 | composerMenuItemsRef.current = composerMenuItems; | |
| 768 | activeComposerMenuItemRef.current = activeComposerMenuItem; | |
| 769 | ||
| 770 | const nonPersistedComposerImageIdSet = useMemo( | |
| 771 | () => new Set(nonPersistedComposerImageIds), | |
| 772 | [nonPersistedComposerImageIds], | |
| 773 | ); | |
| 774 | ||
| 775 | const isComposerApprovalState = activePendingApproval !== null; | |
| 776 | const activePendingUserInput = pendingUserInputs[0] ?? null; | |
| 777 | const hasComposerHeader = | |
| 778 | isComposerApprovalState || | |
| 779 | pendingUserInputs.length > 0 || | |
| 780 | (showPlanFollowUpPrompt && activeProposedPlan !== null); | |
| 781 | ||
| 782 | const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; | |
| 783 | const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); | |
| 784 | const composerFooterActionLayoutKey = useMemo(() => { | |
| 785 | if (activePendingProgress) { | |
| 786 | return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; | |
| 787 | } | |
| 788 | if (phase === "running") { | |
| 789 | return "running"; | |
| 790 | } | |
| 791 | if (showPlanFollowUpPrompt) { | |
| 792 | return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; | |
| 793 | } | |
| 794 | return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; | |
| 795 | }, [ | |
| 796 | activePendingIsResponding, | |
| 797 | activePendingProgress, | |
| 798 | composerSendState.hasSendableContent, | |
| 799 | isConnecting, | |
| 800 | isPreparingWorktree, | |
| 801 | isSendBusy, | |
| 802 | phase, | |
| 803 | prompt, | |
| 804 | showPlanFollowUpPrompt, | |
| 805 | ]); | |
| 806 | ||
| 807 | const isComposerMenuLoading = | |
| 808 | composerTriggerKind === "path" && | |
| 809 | ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || | |
| 810 | workspaceEntriesQuery.isLoading || | |
| 811 | workspaceEntriesQuery.isFetching); | |
| 812 | ||
| 813 | // ------------------------------------------------------------------ | |
| 814 | // Provider traits UI | |
| 815 | // ------------------------------------------------------------------ | |
| 816 | const setPromptFromTraits = useCallback( | |
| 817 | (nextPrompt: string) => { | |
| 818 | const currentPrompt = promptRef.current; | |
| 819 | if (nextPrompt === currentPrompt) { | |
| 820 | scheduleComposerFocus(); | |
| 821 | return; | |
| 822 | } | |
| 823 | promptRef.current = nextPrompt; | |
| 824 | setComposerDraftPrompt(composerDraftTarget, nextPrompt); | |
| 825 | const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); | |
| 826 | setComposerCursor(nextCursor); | |
| 827 | setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); | |
| 828 | scheduleComposerFocus(); | |
| 829 | }, | |
| 830 | [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], | |
| 831 | ); | |
| 832 | ||
| 833 | const providerTraitsMenuContent = renderProviderTraitsMenuContent({ | |
| 834 | provider: selectedProvider, | |
| 835 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), | |
| 836 | ...(routeKind === "draft" && draftId ? { draftId } : {}), | |
| 837 | model: selectedModel, | |
| 838 | models: selectedProviderModels, | |
| 839 | modelOptions: composerModelOptions?.[selectedProvider], | |
| 840 | prompt, | |
| 841 | onPromptChange: setPromptFromTraits, | |
| 842 | }); | |
| 843 | const providerTraitsPicker = renderProviderTraitsPicker({ | |
| 844 | provider: selectedProvider, | |
| 845 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), | |
| 846 | ...(routeKind === "draft" && draftId ? { draftId } : {}), | |
| 847 | model: selectedModel, | |
| 848 | models: selectedProviderModels, | |
| 849 | modelOptions: composerModelOptions?.[selectedProvider], | |
| 850 | prompt, | |
| 851 | onPromptChange: setPromptFromTraits, | |
| 852 | }); | |
| 853 | const pendingPrimaryAction = useMemo( | |
| 854 | () => | |
| 855 | activePendingProgress | |
| 856 | ? { | |
| 857 | questionIndex: activePendingProgress.questionIndex, | |
| 858 | isLastQuestion: activePendingProgress.isLastQuestion, | |
| 859 | canAdvance: activePendingProgress.canAdvance, | |
| 860 | isResponding: activePendingIsResponding, | |
| 861 | isComplete: Boolean(activePendingResolvedAnswers), | |
| 862 | } | |
| 863 | : null, | |
| 864 | [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], | |
| 865 | ); | |
| 866 | ||
| 867 | // ------------------------------------------------------------------ | |
| 868 | // Prompt helpers | |
| 869 | // ------------------------------------------------------------------ | |
| 870 | const setPrompt = useCallback( | |
| 871 | (nextPrompt: string) => { | |
| 872 | setComposerDraftPrompt(composerDraftTarget, nextPrompt); | |
| 873 | }, | |
| 874 | [composerDraftTarget, setComposerDraftPrompt], | |
| 875 | ); | |
| 876 | ||
| 877 | const addComposerImage = useCallback( | |
| 878 | (image: ComposerImageAttachment) => { | |
| 879 | addComposerDraftImage(composerDraftTarget, image); | |
| 880 | }, | |
| 881 | [composerDraftTarget, addComposerDraftImage], | |
| 882 | ); | |
| 883 | ||
| 884 | const addComposerImagesToDraft = useCallback( | |
| 885 | (images: ComposerImageAttachment[]) => { | |
| 886 | addComposerDraftImages(composerDraftTarget, images); | |
| 887 | }, | |
| 888 | [composerDraftTarget, addComposerDraftImages], | |
| 889 | ); | |
| 890 | ||
| 891 | const removeComposerImageFromDraft = useCallback( | |
| 892 | (imageId: string) => { | |
| 893 | removeComposerDraftImage(composerDraftTarget, imageId); | |
| 894 | }, | |
| 895 | [composerDraftTarget, removeComposerDraftImage], | |
| 896 | ); | |
| 897 | ||
| 898 | const removeComposerTerminalContextFromDraft = useCallback( | |
| 899 | (contextId: string) => { | |
| 900 | const contextIndex = composerTerminalContexts.findIndex( | |
| 901 | (context) => context.id === contextId, | |
| 902 | ); | |
| 903 | if (contextIndex < 0) return; | |
| 904 | const removal = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); | |
| 905 | promptRef.current = removal.prompt; | |
| 906 | setPrompt(removal.prompt); | |
| 907 | removeComposerDraftTerminalContext(composerDraftTarget, contextId); | |
| 908 | const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor); | |
| 909 | setComposerCursor(nextCursor); | |
| 910 | setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor)); | |
| 911 | }, | |
| 912 | [ | |
| 913 | composerDraftTarget, | |
| 914 | composerTerminalContexts, | |
| 915 | promptRef, | |
| 916 | removeComposerDraftTerminalContext, | |
| 917 | setPrompt, | |
| 918 | ], | |
| 919 | ); | |
| 920 | ||
| 921 | // ------------------------------------------------------------------ | |
| 922 | // Sync refs back to parent | |
| 923 | // ------------------------------------------------------------------ | |
| 924 | useEffect(() => { | |
| 925 | promptRef.current = prompt; | |
| 926 | setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); | |
| 927 | }, [prompt, promptRef]); | |
| 928 | ||
| 929 | useEffect(() => { | |
| 930 | composerImagesRef.current = composerImages; | |
| 931 | }, [composerImages, composerImagesRef]); | |
| 932 | ||
| 933 | useEffect(() => { | |
| 934 | composerTerminalContextsRef.current = composerTerminalContexts; | |
| 935 | }, [composerTerminalContexts, composerTerminalContextsRef]); | |
| 936 | ||
| 937 | // ------------------------------------------------------------------ | |
| 938 | // Composer menu highlight sync | |
| 939 | // ------------------------------------------------------------------ | |
| 940 | useEffect(() => { | |
| 941 | if (!composerMenuOpen) { | |
| 942 | setComposerHighlightedItemId(null); | |
| 943 | return; | |
| 944 | } | |
| 945 | setComposerHighlightedItemId((existing) => | |
| 946 | existing && composerMenuItems.some((item) => item.id === existing) | |
| 947 | ? existing | |
| 948 | : (composerMenuItems[0]?.id ?? null), | |
| 949 | ); | |
| 950 | }, [composerMenuItems, composerMenuOpen]); | |
| 951 | ||
| 952 | const lastSyncedPendingInputRef = useRef<{ | |
| 953 | requestId: string | null; | |
| 954 | questionId: string | null; | |
| 955 | } | null>(null); | |
| 956 | ||
| 957 | useEffect(() => { | |
| 958 | const nextCustomAnswer = activePendingProgress?.customAnswer; | |
| 959 | if (typeof nextCustomAnswer !== "string") { | |
| 960 | lastSyncedPendingInputRef.current = null; | |
| 961 | return; | |
| 962 | } | |
| 963 | ||
| 964 | const nextRequestId = activePendingUserInput?.requestId ?? null; | |
| 965 | const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; | |
| 966 | const questionChanged = | |
| 967 | lastSyncedPendingInputRef.current?.requestId !== nextRequestId || | |
| 968 | lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; | |
| 969 | const textChangedExternally = promptRef.current !== nextCustomAnswer; | |
| 970 | ||
| 971 | lastSyncedPendingInputRef.current = { | |
| 972 | requestId: nextRequestId, | |
| 973 | questionId: nextQuestionId, | |
| 974 | }; | |
| 975 | ||
| 976 | if (!questionChanged && !textChangedExternally) { | |
| 977 | return; | |
| 978 | } | |
| 979 | ||
| 980 | promptRef.current = nextCustomAnswer; | |
| 981 | const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); | |
| 982 | setComposerCursor(nextCursor); | |
| 983 | setComposerTrigger( | |
| 984 | detectComposerTrigger( | |
| 985 | nextCustomAnswer, | |
| 986 | expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), | |
| 987 | ), | |
| 988 | ); | |
| 989 | setComposerHighlightedItemId(null); | |
| 990 | }, [ | |
| 991 | activePendingProgress?.customAnswer, | |
| 992 | activePendingProgress?.activeQuestion?.id, | |
| 993 | activePendingUserInput?.requestId, | |
| 994 | promptRef, | |
| 995 | ]); | |
| 996 | ||
| 997 | // ------------------------------------------------------------------ | |
| 998 | // Reset compositor state on thread/draft change | |
| 999 | // ------------------------------------------------------------------ | |
| 1000 | useEffect(() => { | |
| 1001 | setComposerHighlightedItemId(null); | |
| 1002 | setComposerCursor( | |
| 1003 | collapseExpandedComposerCursor(promptRef.current, promptRef.current.length), | |
| 1004 | ); | |
| 1005 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); | |
| 1006 | dragDepthRef.current = 0; | |
| 1007 | setIsDragOverComposer(false); | |
| 1008 | }, [draftId, activeThreadId, promptRef]); | |
| 1009 | ||
| 1010 | // ------------------------------------------------------------------ | |
| 1011 | // Footer compact layout observation | |
| 1012 | // ------------------------------------------------------------------ | |
| 1013 | useLayoutEffect(() => { | |
| 1014 | const composerForm = composerFormRef.current; | |
| 1015 | if (!composerForm) return; | |
| 1016 | const measureComposerFormWidth = () => composerForm.clientWidth; | |
| 1017 | const measureFooterCompactness = () => { | |
| 1018 | const composerFormWidth = measureComposerFormWidth(); | |
| 1019 | const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, { | |
| 1020 | hasWideActions: composerFooterHasWideActions, | |
| 1021 | }); | |
| 1022 | const footer = composerFooterRef.current; | |
| 1023 | const footerStyle = footer ? window.getComputedStyle(footer) : null; | |
| 1024 | const footerContentWidth = resolveComposerFooterContentWidth({ | |
| 1025 | footerWidth: footer?.clientWidth ?? null, | |
| 1026 | paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null, | |
| 1027 | paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null, | |
| 1028 | }); | |
| 1029 | const fitInput = { | |
| 1030 | footerContentWidth, | |
| 1031 | leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null, | |
| 1032 | actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null, | |
| 1033 | }; | |
| 1034 | const nextFooterCompact = | |
| 1035 | heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput); | |
| 1036 | const nextPrimaryActionsCompact = | |
| 1037 | nextFooterCompact && | |
| 1038 | shouldUseCompactComposerPrimaryActions(composerFormWidth, { | |
| 1039 | hasWideActions: composerFooterHasWideActions, | |
| 1040 | }); | |
| 1041 | return { | |
| 1042 | primaryActionsCompact: nextPrimaryActionsCompact, | |
| 1043 | footerCompact: nextFooterCompact, | |
| 1044 | }; | |
| 1045 | }; | |
| 1046 | ||
| 1047 | composerFormHeightRef.current = composerForm.getBoundingClientRect().height; | |
| 1048 | const initialCompactness = measureFooterCompactness(); | |
| 1049 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); | |
| 1050 | setIsComposerFooterCompact(initialCompactness.footerCompact); | |
| 1051 | if (typeof ResizeObserver === "undefined") return; | |
| 1052 | ||
| 1053 | const observer = new ResizeObserver((entries) => { | |
| 1054 | const [entry] = entries; | |
| 1055 | if (!entry) return; | |
| 1056 | const nextCompactness = measureFooterCompactness(); | |
| 1057 | setIsComposerPrimaryActionsCompact((previous) => | |
| 1058 | previous === nextCompactness.primaryActionsCompact | |
| 1059 | ? previous | |
| 1060 | : nextCompactness.primaryActionsCompact, | |
| 1061 | ); | |
| 1062 | setIsComposerFooterCompact((previous) => | |
| 1063 | previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, | |
| 1064 | ); | |
| 1065 | const nextHeight = entry.contentRect.height; | |
| 1066 | const previousHeight = composerFormHeightRef.current; | |
| 1067 | composerFormHeightRef.current = nextHeight; | |
| 1068 | if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; | |
| 1069 | if (!shouldAutoScrollRef.current) return; | |
| 1070 | scheduleStickToBottom(); | |
| 1071 | }); | |
| 1072 | ||
| 1073 | observer.observe(composerForm); | |
| 1074 | return () => { | |
| 1075 | observer.disconnect(); | |
| 1076 | }; | |
| 1077 | }, [ | |
| 1078 | activeThreadId, | |
| 1079 | composerFooterActionLayoutKey, | |
| 1080 | composerFooterHasWideActions, | |
| 1081 | scheduleStickToBottom, | |
| 1082 | shouldAutoScrollRef, | |
| 1083 | ]); | |
| 1084 | ||
| 1085 | // ------------------------------------------------------------------ | |
| 1086 | // Image persist effect | |
| 1087 | // ------------------------------------------------------------------ | |
| 1088 | useEffect(() => { | |
| 1089 | let cancelled = false; | |
| 1090 | void (async () => { | |
| 1091 | if (composerImages.length === 0) { | |
| 1092 | clearComposerDraftPersistedAttachments(composerDraftTarget); | |
| 1093 | return; | |
| 1094 | } | |
| 1095 | const getPersistedAttachmentsForThread = () => | |
| 1096 | getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; | |
| 1097 | try { | |
| 1098 | const currentPersistedAttachments = getPersistedAttachmentsForThread(); | |
| 1099 | const existingPersistedById = new Map( | |
| 1100 | currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), | |
| 1101 | ); | |
| 1102 | const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>(); | |
| 1103 | await Promise.all( | |
| 1104 | composerImages.map(async (image) => { | |
| 1105 | try { | |
| 1106 | const dataUrl = await readFileAsDataUrl(image.file); | |
| 1107 | stagedAttachmentById.set(image.id, { | |
| 1108 | id: image.id, | |
| 1109 | name: image.name, | |
| 1110 | mimeType: image.mimeType, | |
| 1111 | sizeBytes: image.sizeBytes, | |
| 1112 | dataUrl, | |
| 1113 | }); | |
| 1114 | } catch { | |
| 1115 | const existingPersisted = existingPersistedById.get(image.id); | |
| 1116 | if (existingPersisted) { | |
| 1117 | stagedAttachmentById.set(image.id, existingPersisted); | |
| 1118 | } | |
| 1119 | } | |
| 1120 | }), | |
| 1121 | ); | |
| 1122 | const serialized = Array.from(stagedAttachmentById.values()); | |
| 1123 | if (cancelled) return; | |
| 1124 | syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); | |
| 1125 | } catch { | |
| 1126 | const currentImageIds = new Set(composerImages.map((image) => image.id)); | |
| 1127 | const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); | |
| 1128 | const fallbackPersistedIds = fallbackPersistedAttachments | |
| 1129 | .map((attachment) => attachment.id) | |
| 1130 | .filter((id) => currentImageIds.has(id)); | |
| 1131 | const fallbackPersistedIdSet = new Set(fallbackPersistedIds); | |
| 1132 | const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => | |
| 1133 | fallbackPersistedIdSet.has(attachment.id), | |
| 1134 | ); | |
| 1135 | if (cancelled) return; | |
| 1136 | syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); | |
| 1137 | } | |
| 1138 | })(); | |
| 1139 | return () => { | |
| 1140 | cancelled = true; | |
| 1141 | }; | |
| 1142 | }, [ | |
| 1143 | composerDraftTarget, | |
| 1144 | clearComposerDraftPersistedAttachments, | |
| 1145 | composerImages, | |
| 1146 | getComposerDraft, | |
| 1147 | syncComposerDraftPersistedAttachments, | |
| 1148 | ]); | |
| 1149 | ||
| 1150 | // ------------------------------------------------------------------ | |
| 1151 | // Callbacks: prompt change | |
| 1152 | // ------------------------------------------------------------------ | |
| 1153 | const onPromptChange = useCallback( | |
| 1154 | ( | |
| 1155 | nextPrompt: string, | |
| 1156 | nextCursor: number, | |
| 1157 | expandedCursor: number, | |
| 1158 | cursorAdjacentToMention: boolean, | |
| 1159 | terminalContextIds: string[], | |
| 1160 | ) => { | |
| 1161 | if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { | |
| 1162 | setComposerCursor(nextCursor); | |
| 1163 | setComposerTrigger( | |
| 1164 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), | |
| 1165 | ); | |
| 1166 | onChangeActivePendingUserInputCustomAnswer( | |
| 1167 | activePendingProgress.activeQuestion.id, | |
| 1168 | nextPrompt, | |
| 1169 | nextCursor, | |
| 1170 | expandedCursor, | |
| 1171 | cursorAdjacentToMention, | |
| 1172 | ); | |
| 1173 | return; | |
| 1174 | } | |
| 1175 | promptRef.current = nextPrompt; | |
| 1176 | setPrompt(nextPrompt); | |
| 1177 | if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { | |
| 1178 | setComposerDraftTerminalContexts( | |
| 1179 | composerDraftTarget, | |
| 1180 | syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), | |
| 1181 | ); | |
| 1182 | } | |
| 1183 | setComposerCursor(nextCursor); | |
| 1184 | setComposerTrigger( | |
| 1185 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), | |
| 1186 | ); | |
| 1187 | }, | |
| 1188 | [ | |
| 1189 | activePendingProgress?.activeQuestion, | |
| 1190 | pendingUserInputs.length, | |
| 1191 | onChangeActivePendingUserInputCustomAnswer, | |
| 1192 | promptRef, | |
| 1193 | setPrompt, | |
| 1194 | composerDraftTarget, | |
| 1195 | composerTerminalContexts, | |
| 1196 | setComposerDraftTerminalContexts, | |
| 1197 | ], | |
| 1198 | ); | |
| 1199 | ||
| 1200 | // ------------------------------------------------------------------ | |
| 1201 | // Callbacks: prompt replacement / menu | |
| 1202 | // ------------------------------------------------------------------ | |
| 1203 | const applyPromptReplacement = useCallback( | |
| 1204 | ( | |
| 1205 | rangeStart: number, | |
| 1206 | rangeEnd: number, | |
| 1207 | replacement: string, | |
| 1208 | options?: { expectedText?: string }, | |
| 1209 | ): boolean => { | |
| 1210 | const currentText = promptRef.current; | |
| 1211 | const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); | |
| 1212 | const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); | |
| 1213 | if ( | |
| 1214 | options?.expectedText !== undefined && | |
| 1215 | currentText.slice(safeStart, safeEnd) !== options.expectedText | |
| 1216 | ) { | |
| 1217 | return false; | |
| 1218 | } | |
| 1219 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); | |
| 1220 | const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); | |
| 1221 | const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); | |
| 1222 | promptRef.current = next.text; | |
| 1223 | const activePendingQuestion = activePendingProgress?.activeQuestion; | |
| 1224 | if (activePendingQuestion && activePendingUserInput) { | |
| 1225 | onChangeActivePendingUserInputCustomAnswer( | |
| 1226 | activePendingQuestion.id, | |
| 1227 | next.text, | |
| 1228 | nextCursor, | |
| 1229 | nextExpandedCursor, | |
| 1230 | false, | |
| 1231 | ); | |
| 1232 | } else { | |
| 1233 | setPrompt(next.text); | |
| 1234 | } | |
| 1235 | setComposerCursor(nextCursor); | |
| 1236 | setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); | |
| 1237 | window.requestAnimationFrame(() => { | |
| 1238 | composerEditorRef.current?.focusAt(nextCursor); | |
| 1239 | }); | |
| 1240 | return true; | |
| 1241 | }, | |
| 1242 | [ | |
| 1243 | activePendingProgress?.activeQuestion, | |
| 1244 | activePendingUserInput, | |
| 1245 | onChangeActivePendingUserInputCustomAnswer, | |
| 1246 | promptRef, | |
| 1247 | setPrompt, | |
| 1248 | ], | |
| 1249 | ); | |
| 1250 | ||
| 1251 | const readComposerSnapshot = useCallback((): { | |
| 1252 | value: string; | |
| 1253 | cursor: number; | |
| 1254 | expandedCursor: number; | |
| 1255 | terminalContextIds: string[]; | |
| 1256 | } => { | |
| 1257 | const editorSnapshot = composerEditorRef.current?.readSnapshot(); | |
| 1258 | if (editorSnapshot) { | |
| 1259 | return editorSnapshot; | |
| 1260 | } | |
| 1261 | return { | |
| 1262 | value: promptRef.current, | |
| 1263 | cursor: composerCursor, | |
| 1264 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), | |
| 1265 | terminalContextIds: composerTerminalContexts.map((context) => context.id), | |
| 1266 | }; | |
| 1267 | }, [composerCursor, composerTerminalContexts, promptRef]); | |
| 1268 | ||
| 1269 | const resolveActiveComposerTrigger = useCallback((): { | |
| 1270 | snapshot: { value: string; cursor: number; expandedCursor: number }; | |
| 1271 | trigger: ComposerTrigger | null; | |
| 1272 | } => { | |
| 1273 | const snapshot = readComposerSnapshot(); | |
| 1274 | return { | |
| 1275 | snapshot, | |
| 1276 | trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), | |
| 1277 | }; | |
| 1278 | }, [readComposerSnapshot]); | |
| 1279 | ||
| 1280 | const onSelectComposerItem = useCallback( | |
| 1281 | (item: ComposerCommandItem) => { | |
| 1282 | if (composerSelectLockRef.current) return; | |
| 1283 | composerSelectLockRef.current = true; | |
| 1284 | window.requestAnimationFrame(() => { | |
| 1285 | composerSelectLockRef.current = false; | |
| 1286 | }); | |
| 1287 | const { snapshot, trigger } = resolveActiveComposerTrigger(); | |
| 1288 | if (!trigger) return; | |
| 1289 | if (item.type === "path") { | |
| 1290 | const replacement = `@${item.path} `; | |
| 1291 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | |
| 1292 | snapshot.value, | |
| 1293 | trigger.rangeEnd, | |
| 1294 | replacement, | |
| 1295 | ); | |
| 1296 | const applied = applyPromptReplacement( | |
| 1297 | trigger.rangeStart, | |
| 1298 | replacementRangeEnd, | |
| 1299 | replacement, | |
| 1300 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, | |
| 1301 | ); | |
| 1302 | if (applied) { | |
| 1303 | setComposerHighlightedItemId(null); | |
| 1304 | } | |
| 1305 | return; | |
| 1306 | } | |
| 1307 | if (item.type === "slash-command") { | |
| 1308 | if (item.command === "model") { | |
| 1309 | const replacement = "/model "; | |
| 1310 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | |
| 1311 | snapshot.value, | |
| 1312 | trigger.rangeEnd, | |
| 1313 | replacement, | |
| 1314 | ); | |
| 1315 | const applied = applyPromptReplacement( | |
| 1316 | trigger.rangeStart, | |
| 1317 | replacementRangeEnd, | |
| 1318 | replacement, | |
| 1319 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, | |
| 1320 | ); | |
| 1321 | if (applied) { | |
| 1322 | setComposerHighlightedItemId(null); | |
| 1323 | } | |
| 1324 | return; | |
| 1325 | } | |
| 1326 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); | |
| 1327 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | |
| 1328 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), | |
| 1329 | }); | |
| 1330 | if (applied) { | |
| 1331 | setComposerHighlightedItemId(null); | |
| 1332 | } | |
| 1333 | return; | |
| 1334 | } | |
| 1335 | onProviderModelSelect(item.provider, item.model); | |
| 1336 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | |
| 1337 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), | |
| 1338 | }); | |
| 1339 | if (applied) { | |
| 1340 | setComposerHighlightedItemId(null); | |
| 1341 | } | |
| 1342 | }, | |
| 1343 | [ | |
| 1344 | applyPromptReplacement, | |
| 1345 | handleInteractionModeChange, | |
| 1346 | onProviderModelSelect, | |
| 1347 | resolveActiveComposerTrigger, | |
| 1348 | ], | |
| 1349 | ); | |
| 1350 | ||
| 1351 | const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { | |
| 1352 | setComposerHighlightedItemId(itemId); | |
| 1353 | }, []); | |
| 1354 | ||
| 1355 | const nudgeComposerMenuHighlight = useCallback( | |
| 1356 | (key: "ArrowDown" | "ArrowUp") => { | |
| 1357 | if (composerMenuItems.length === 0) return; | |
| 1358 | const highlightedIndex = composerMenuItems.findIndex( | |
| 1359 | (item) => item.id === composerHighlightedItemId, | |
| 1360 | ); | |
| 1361 | const normalizedIndex = | |
| 1362 | highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; | |
| 1363 | const offset = key === "ArrowDown" ? 1 : -1; | |
| 1364 | const nextIndex = | |
| 1365 | (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; | |
| 1366 | const nextItem = composerMenuItems[nextIndex]; | |
| 1367 | setComposerHighlightedItemId(nextItem?.id ?? null); | |
| 1368 | }, | |
| 1369 | [composerHighlightedItemId, composerMenuItems], | |
| 1370 | ); | |
| 1371 | ||
| 1372 | // ------------------------------------------------------------------ | |
| 1373 | // Callbacks: command key | |
| 1374 | // ------------------------------------------------------------------ | |
| 1375 | const onComposerCommandKey = ( | |
| 1376 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | |
| 1377 | event: KeyboardEvent, | |
| 1378 | ) => { | |
| 1379 | if (key === "Tab" && event.shiftKey) { | |
| 1380 | toggleInteractionMode(); | |
| 1381 | return true; | |
| 1382 | } | |
| 1383 | const { trigger } = resolveActiveComposerTrigger(); | |
| 1384 | const menuIsActive = composerMenuOpenRef.current || trigger !== null; | |
| 1385 | if (menuIsActive) { | |
| 1386 | const currentItems = composerMenuItemsRef.current; | |
| 1387 | const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; | |
| 1388 | if (key === "ArrowDown" && currentItems.length > 0) { | |
| 1389 | nudgeComposerMenuHighlight("ArrowDown"); | |
| 1390 | return true; | |
| 1391 | } | |
| 1392 | if (key === "ArrowUp" && currentItems.length > 0) { | |
| 1393 | nudgeComposerMenuHighlight("ArrowUp"); | |
| 1394 | return true; | |
| 1395 | } | |
| 1396 | if ((key === "Enter" || key === "Tab") && selectedItem) { | |
| 1397 | onSelectComposerItem(selectedItem); | |
| 1398 | return true; | |
| 1399 | } | |
| 1400 | } | |
| 1401 | if (key === "Enter" && !event.shiftKey) { | |
| 1402 | void onSend(); | |
| 1403 | return true; | |
| 1404 | } | |
| 1405 | return false; | |
| 1406 | }; | |
| 1407 | ||
| 1408 | // ------------------------------------------------------------------ | |
| 1409 | // Callbacks: images | |
| 1410 | // ------------------------------------------------------------------ | |
| 1411 | const addComposerImages = (files: File[]) => { | |
| 1412 | if (!activeThreadId || files.length === 0) return; | |
| 1413 | if (pendingUserInputs.length > 0) { | |
| 1414 | toastManager.add({ | |
| 1415 | type: "error", | |
| 1416 | title: "Attach images after answering plan questions.", | |
| 1417 | }); | |
| 1418 | return; | |
| 1419 | } | |
| 1420 | const nextImages: ComposerImageAttachment[] = []; | |
| 1421 | let nextImageCount = composerImagesRef.current.length; | |
| 1422 | let error: string | null = null; | |
| 1423 | for (const file of files) { | |
| 1424 | if (!file.type.startsWith("image/")) { | |
| 1425 | error = `Unsupported file type for '${file.name}'. Please attach image files only.`; | |
| 1426 | continue; | |
| 1427 | } | |
| 1428 | if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | |
| 1429 | error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; | |
| 1430 | continue; | |
| 1431 | } | |
| 1432 | if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | |
| 1433 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; | |
| 1434 | break; | |
| 1435 | } | |
| 1436 | const previewUrl = URL.createObjectURL(file); | |
| 1437 | nextImages.push({ | |
| 1438 | type: "image", | |
| 1439 | id: randomUUID(), | |
| 1440 | name: file.name || "image", | |
| 1441 | mimeType: file.type, | |
| 1442 | sizeBytes: file.size, | |
| 1443 | previewUrl, | |
| 1444 | file, | |
| 1445 | }); | |
| 1446 | nextImageCount += 1; | |
| 1447 | } | |
| 1448 | if (nextImages.length === 1 && nextImages[0]) { | |
| 1449 | addComposerImage(nextImages[0]); | |
| 1450 | } else if (nextImages.length > 1) { | |
| 1451 | addComposerImagesToDraft(nextImages); | |
| 1452 | } | |
| 1453 | setThreadError(activeThreadId, error); | |
| 1454 | }; | |
| 1455 | ||
| 1456 | const removeComposerImage = (imageId: string) => { | |
| 1457 | removeComposerImageFromDraft(imageId); | |
| 1458 | }; | |
| 1459 | ||
| 1460 | // ------------------------------------------------------------------ | |
| 1461 | // Callbacks: paste / drag | |
| 1462 | // ------------------------------------------------------------------ | |
| 1463 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { | |
| 1464 | const files = Array.from(event.clipboardData.files); | |
| 1465 | if (files.length === 0) return; | |
| 1466 | const imageFiles = files.filter((file) => file.type.startsWith("image/")); | |
| 1467 | if (imageFiles.length === 0) return; | |
| 1468 | event.preventDefault(); | |
| 1469 | addComposerImages(imageFiles); | |
| 1470 | }; | |
| 1471 | ||
| 1472 | const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => { | |
| 1473 | if (!event.dataTransfer.types.includes("Files")) return; | |
| 1474 | event.preventDefault(); | |
| 1475 | dragDepthRef.current += 1; | |
| 1476 | setIsDragOverComposer(true); | |
| 1477 | }; | |
| 1478 | ||
| 1479 | const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => { | |
| 1480 | if (!event.dataTransfer.types.includes("Files")) return; | |
| 1481 | event.preventDefault(); | |
| 1482 | event.dataTransfer.dropEffect = "copy"; | |
| 1483 | setIsDragOverComposer(true); | |
| 1484 | }; | |
| 1485 | ||
| 1486 | const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => { | |
| 1487 | if (!event.dataTransfer.types.includes("Files")) return; | |
| 1488 | event.preventDefault(); | |
| 1489 | const nextTarget = event.relatedTarget; | |
| 1490 | if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; | |
| 1491 | dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); | |
| 1492 | if (dragDepthRef.current === 0) { | |
| 1493 | setIsDragOverComposer(false); | |
| 1494 | } | |
| 1495 | }; | |
| 1496 | ||
| 1497 | const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => { | |
| 1498 | if (!event.dataTransfer.types.includes("Files")) return; | |
| 1499 | event.preventDefault(); | |
| 1500 | dragDepthRef.current = 0; | |
| 1501 | setIsDragOverComposer(false); | |
| 1502 | const files = Array.from(event.dataTransfer.files); | |
| 1503 | addComposerImages(files); | |
| 1504 | focusComposer(); | |
| 1505 | }; | |
| 1506 | const handleInterruptPrimaryAction = useCallback(() => { | |
| 1507 | void onInterrupt(); | |
| 1508 | }, [onInterrupt]); | |
| 1509 | const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { | |
| 1510 | void onImplementPlanInNewThread(); | |
| 1511 | }, [onImplementPlanInNewThread]); | |
| 1512 | ||
| 1513 | // ------------------------------------------------------------------ | |
| 1514 | // Imperative handle | |
| 1515 | // ------------------------------------------------------------------ | |
| 1516 | useImperativeHandle( | |
| 1517 | ref, | |
| 1518 | () => ({ | |
| 1519 | focusAtEnd: () => { | |
| 1520 | composerEditorRef.current?.focusAtEnd(); | |
| 1521 | }, | |
| 1522 | focusAt: (cursor: number) => { | |
| 1523 | composerEditorRef.current?.focusAt(cursor); | |
| 1524 | }, | |
| 1525 | readSnapshot: () => { | |
| 1526 | return readComposerSnapshot(); | |
| 1527 | }, | |
| 1528 | resetCursorState: (options?: { | |
| 1529 | cursor?: number; | |
| 1530 | prompt?: string; | |
| 1531 | detectTrigger?: boolean; | |
| 1532 | }) => { | |
| 1533 | const promptForState = options?.prompt ?? promptRef.current; | |
| 1534 | const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); | |
| 1535 | setComposerHighlightedItemId(null); | |
| 1536 | setComposerCursor(cursor); | |
| 1537 | setComposerTrigger( | |
| 1538 | options?.detectTrigger | |
| 1539 | ? detectComposerTrigger( | |
| 1540 | promptForState, | |
| 1541 | expandCollapsedComposerCursor(promptForState, cursor), | |
| 1542 | ) | |
| 1543 | : null, | |
| 1544 | ); | |
| 1545 | }, | |
| 1546 | addTerminalContext: (selection: TerminalContextSelection) => { | |
| 1547 | if (!activeThread) return; | |
| 1548 | const snapshot = composerEditorRef.current?.readSnapshot() ?? { | |
| 1549 | value: promptRef.current, | |
| 1550 | cursor: composerCursor, | |
| 1551 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), | |
| 1552 | terminalContextIds: composerTerminalContexts.map((context) => context.id), | |
| 1553 | }; | |
| 1554 | const insertion = insertInlineTerminalContextPlaceholder( | |
| 1555 | snapshot.value, | |
| 1556 | snapshot.expandedCursor, | |
| 1557 | ); | |
| 1558 | const nextCollapsedCursor = collapseExpandedComposerCursor( | |
| 1559 | insertion.prompt, | |
| 1560 | insertion.cursor, | |
| 1561 | ); | |
| 1562 | const inserted = insertComposerDraftTerminalContext( | |
| 1563 | composerDraftTarget, | |
| 1564 | insertion.prompt, | |
| 1565 | { | |
| 1566 | id: randomUUID(), | |
| 1567 | threadId: activeThread.id, | |
| 1568 | createdAt: new Date().toISOString(), | |
| 1569 | ...selection, | |
| 1570 | }, | |
| 1571 | insertion.contextIndex, | |
| 1572 | ); | |
| 1573 | if (!inserted) return; | |
| 1574 | promptRef.current = insertion.prompt; | |
| 1575 | setComposerCursor(nextCollapsedCursor); | |
| 1576 | setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); | |
| 1577 | window.requestAnimationFrame(() => { | |
| 1578 | composerEditorRef.current?.focusAt(nextCollapsedCursor); | |
| 1579 | }); | |
| 1580 | }, | |
| 1581 | getSendContext: () => ({ | |
| 1582 | prompt: promptRef.current, | |
| 1583 | images: composerImagesRef.current, | |
| 1584 | terminalContexts: composerTerminalContextsRef.current, | |
| 1585 | selectedPromptEffort, | |
| 1586 | selectedModelOptionsForDispatch, | |
| 1587 | selectedModelSelection, | |
| 1588 | selectedProvider, | |
| 1589 | selectedModel, | |
| 1590 | selectedProviderModels, | |
| 1591 | }), | |
| 1592 | }), | |
| 1593 | [ | |
| 1594 | activeThread, | |
| 1595 | composerDraftTarget, | |
| 1596 | composerCursor, | |
| 1597 | composerTerminalContexts, | |
| 1598 | insertComposerDraftTerminalContext, | |
| 1599 | promptRef, | |
| 1600 | composerImagesRef, | |
| 1601 | composerTerminalContextsRef, | |
| 1602 | readComposerSnapshot, | |
| 1603 | selectedModel, | |
| 1604 | selectedModelOptionsForDispatch, | |
| 1605 | selectedModelSelection, | |
| 1606 | selectedPromptEffort, | |
| 1607 | selectedProvider, | |
| 1608 | selectedProviderModels, | |
| 1609 | ], | |
| 1610 | ); | |
| 1611 | ||
| 1612 | // Render | |
| 1613 | // ------------------------------------------------------------------ | |
| 1614 | return ( | |
| 1615 | <form | |
| 1616 | ref={composerFormRef} | |
| 1617 | onSubmit={onSend} | |
| 1618 | className="mx-auto w-full min-w-0 max-w-208" | |
| 1619 | data-chat-composer-form="true" | |
| 1620 | > | |
| 1621 | <div | |
| 1622 | className={cn( | |
| 1623 | "group rounded-[22px] p-px transition-colors duration-200", | |
| 1624 | composerProviderState.composerFrameClassName, | |
| 1625 | )} | |
| 1626 | onDragEnter={onComposerDragEnter} | |
| 1627 | onDragOver={onComposerDragOver} | |
| 1628 | onDragLeave={onComposerDragLeave} | |
| 1629 | onDrop={onComposerDrop} | |
| 1630 | > | |
| 1631 | <div | |
| 1632 | className={cn( | |
| 1633 | "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45", | |
| 1634 | isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border", | |
| 1635 | composerProviderState.composerSurfaceClassName, | |
| 1636 | )} | |
| 1637 | > | |
| 1638 | {activePendingApproval ? ( | |
| 1639 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> | |
| 1640 | <ComposerPendingApprovalPanel | |
| 1641 | approval={activePendingApproval} | |
| 1642 | pendingCount={pendingApprovals.length} | |
| 1643 | /> | |
| 1644 | </div> | |
| 1645 | ) : pendingUserInputs.length > 0 ? ( | |
| 1646 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> | |
| 1647 | <ComposerPendingUserInputPanel | |
| 1648 | pendingUserInputs={pendingUserInputs} | |
| 1649 | respondingRequestIds={respondingRequestIds} | |
| 1650 | answers={activePendingDraftAnswers} | |
| 1651 | questionIndex={activePendingQuestionIndex} | |
| 1652 | onToggleOption={onSelectActivePendingUserInputOption} | |
| 1653 | onAdvance={onAdvanceActivePendingUserInput} | |
| 1654 | /> | |
| 1655 | </div> | |
| 1656 | ) : showPlanFollowUpPrompt && activeProposedPlan ? ( | |
| 1657 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> | |
| 1658 | <ComposerPlanFollowUpBanner | |
| 1659 | key={activeProposedPlan.id} | |
| 1660 | planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null} | |
| 1661 | /> | |
| 1662 | </div> | |
| 1663 | ) : null} | |
| 1664 | ||
| 1665 | <div | |
| 1666 | className={cn( | |
| 1667 | "relative px-3 pb-2 sm:px-4", | |
| 1668 | hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4", | |
| 1669 | )} | |
| 1670 | > | |
| 1671 | {composerMenuOpen && !isComposerApprovalState && ( | |
| 1672 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> | |
| 1673 | <ComposerCommandMenu | |
| 1674 | items={composerMenuItems} | |
| 1675 | resolvedTheme={resolvedTheme} | |
| 1676 | isLoading={isComposerMenuLoading} | |
| 1677 | triggerKind={composerTriggerKind} | |
| 1678 | activeItemId={activeComposerMenuItem?.id ?? null} | |
| 1679 | onHighlightedItemChange={onComposerMenuItemHighlighted} | |
| 1680 | onSelect={onSelectComposerItem} | |
| 1681 | /> | |
| 1682 | </div> | |
| 1683 | )} | |
| 1684 | ||
| 1685 | {!isComposerApprovalState && | |
| 1686 | pendingUserInputs.length === 0 && | |
| 1687 | composerImages.length > 0 && ( | |
| 1688 | <div className="mb-3 flex flex-wrap gap-2"> | |
| 1689 | {composerImages.map((image) => ( | |
| 1690 | <div | |
| 1691 | key={image.id} | |
| 1692 | className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" | |
| 1693 | > | |
| 1694 | {image.previewUrl ? ( | |
| 1695 | <button | |
| 1696 | type="button" | |
| 1697 | className="h-full w-full cursor-zoom-in" | |
| 1698 | aria-label={`Preview ${image.name}`} | |
| 1699 | onClick={() => { | |
| 1700 | const preview = buildExpandedImagePreview(composerImages, image.id); | |
| 1701 | if (!preview) return; | |
| 1702 | onExpandImage(preview); | |
| 1703 | }} | |
| 1704 | > | |
| 1705 | <img | |
| 1706 | src={image.previewUrl} | |
| 1707 | alt={image.name} | |
| 1708 | className="h-full w-full object-cover" | |
| 1709 | /> | |
| 1710 | </button> | |
| 1711 | ) : ( | |
| 1712 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70"> | |
| 1713 | {image.name} | |
| 1714 | </div> | |
| 1715 | )} | |
| 1716 | {nonPersistedComposerImageIdSet.has(image.id) && ( | |
| 1717 | <Tooltip> | |
| 1718 | <TooltipTrigger | |
| 1719 | render={ | |
| 1720 | <span | |
| 1721 | role="img" | |
| 1722 | aria-label="Draft attachment may not persist" | |
| 1723 | className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600" | |
| 1724 | > | |
| 1725 | <CircleAlertIcon className="size-3" /> | |
| 1726 | </span> | |
| 1727 | } | |
| 1728 | /> | |
| 1729 | <TooltipPopup | |
| 1730 | side="top" | |
| 1731 | className="max-w-64 whitespace-normal leading-tight" | |
| 1732 | > | |
| 1733 | Draft attachment could not be saved locally and may be lost on | |
| 1734 | navigation. | |
| 1735 | </TooltipPopup> | |
| 1736 | </Tooltip> | |
| 1737 | )} | |
| 1738 | <Button | |
| 1739 | variant="ghost" | |
| 1740 | size="icon-xs" | |
| 1741 | className="absolute right-1 top-1 bg-background/80 hover:bg-background/90" | |
| 1742 | onClick={() => removeComposerImage(image.id)} | |
| 1743 | aria-label={`Remove ${image.name}`} | |
| 1744 | > | |
| 1745 | <XIcon /> | |
| 1746 | </Button> | |
| 1747 | </div> | |
| 1748 | ))} | |
| 1749 | </div> | |
| 1750 | )} | |
| 1751 | ||
| 1752 | <ComposerPromptEditor | |
| 1753 | ref={composerEditorRef} | |
| 1754 | value={ | |
| 1755 | isComposerApprovalState | |
| 1756 | ? "" | |
| 1757 | : activePendingProgress | |
| 1758 | ? activePendingProgress.customAnswer | |
| 1759 | : prompt | |
| 1760 | } | |
| 1761 | cursor={composerCursor} | |
| 1762 | terminalContexts={ | |
| 1763 | !isComposerApprovalState && pendingUserInputs.length === 0 | |
| 1764 | ? composerTerminalContexts | |
| 1765 | : [] | |
| 1766 | } | |
| 1767 | onRemoveTerminalContext={removeComposerTerminalContextFromDraft} | |
| 1768 | onChange={onPromptChange} | |
| 1769 | onCommandKeyDown={onComposerCommandKey} | |
| 1770 | onPaste={onComposerPaste} | |
| 1771 | placeholder={ | |
| 1772 | isComposerApprovalState | |
| 1773 | ? (activePendingApproval?.detail ?? "Resolve this approval request to continue") | |
| 1774 | : activePendingProgress | |
| 1775 | ? "Type your own answer, or leave this blank to use the selected option" | |
| 1776 | : showPlanFollowUpPrompt && activeProposedPlan | |
| 1777 | ? "Add feedback to refine the plan, or leave this blank to implement it" | |
| 1778 | : phase === "disconnected" | |
| 1779 | ? "Ask for follow-up changes or attach images" | |
| 1780 | : "Ask anything, @tag files/folders, or use / to show available commands" | |
| 1781 | } | |
| 1782 | disabled={isConnecting || isComposerApprovalState} | |
| 1783 | /> | |
| 1784 | </div> | |
| 1785 | ||
| 1786 | {/* Bottom toolbar */} | |
| 1787 | {activePendingApproval ? ( | |
| 1788 | <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3"> | |
| 1789 | <ComposerPendingApprovalActions | |
| 1790 | requestId={activePendingApproval.requestId} | |
| 1791 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} | |
| 1792 | onRespondToApproval={onRespondToApproval} | |
| 1793 | /> | |
| 1794 | </div> | |
| 1795 | ) : ( | |
| 1796 | <div | |
| 1797 | ref={composerFooterRef} | |
| 1798 | data-chat-composer-footer="true" | |
| 1799 | data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"} | |
| 1800 | className={cn( | |
| 1801 | "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-hidden px-2.5 pb-2.5 sm:px-3 sm:pb-3", | |
| 1802 | isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", | |
| 1803 | )} | |
| 1804 | > | |
| 1805 | <div | |
| 1806 | ref={composerFooterLeadingRef} | |
| 1807 | className={cn( | |
| 1808 | "flex min-w-0 flex-1 items-center", | |
| 1809 | isComposerFooterCompact | |
| 1810 | ? "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" | |
| 1811 | : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible", | |
| 1812 | )} | |
| 1813 | > | |
| 1814 | <ProviderModelPicker | |
| 1815 | compact={isComposerFooterCompact} | |
| 1816 | provider={selectedProvider} | |
| 1817 | model={selectedModelForPickerWithCustomFallback} | |
| 1818 | lockedProvider={lockedProvider} | |
| 1819 | providers={providerStatuses} | |
| 1820 | modelOptionsByProvider={modelOptionsByProvider} | |
| 1821 | {...(composerProviderState.modelPickerIconClassName | |
| 1822 | ? { | |
| 1823 | activeProviderIconClassName: | |
| 1824 | composerProviderState.modelPickerIconClassName, | |
| 1825 | } | |
| 1826 | : {})} | |
| 1827 | onProviderModelChange={onProviderModelSelect} | |
| 1828 | /> | |
| 1829 | ||
| 1830 | {isComposerFooterCompact ? ( | |
| 1831 | <CompactComposerControlsMenu | |
| 1832 | activePlan={showPlanSidebarToggle} | |
| 1833 | interactionMode={interactionMode} | |
| 1834 | planSidebarOpen={planSidebarOpen} | |
| 1835 | runtimeMode={runtimeMode} | |
| 1836 | traitsMenuContent={providerTraitsMenuContent} | |
| 1837 | onToggleInteractionMode={toggleInteractionMode} | |
| 1838 | onTogglePlanSidebar={togglePlanSidebar} | |
| 1839 | onRuntimeModeChange={handleRuntimeModeChange} | |
| 1840 | /> | |
| 1841 | ) : ( | |
| 1842 | <> | |
| 1843 | {providerTraitsPicker ? ( | |
| 1844 | <> | |
| 1845 | <Separator | |
| 1846 | orientation="vertical" | |
| 1847 | className="mx-0.5 hidden h-4 sm:block" | |
| 1848 | /> | |
| 1849 | {providerTraitsPicker} | |
| 1850 | </> | |
| 1851 | ) : null} | |
| 1852 | <ComposerFooterModeControls | |
| 1853 | interactionMode={interactionMode} | |
| 1854 | runtimeMode={runtimeMode} | |
| 1855 | showPlanToggle={showPlanSidebarToggle} | |
| 1856 | planSidebarOpen={planSidebarOpen} | |
| 1857 | onToggleInteractionMode={toggleInteractionMode} | |
| 1858 | onRuntimeModeChange={handleRuntimeModeChange} | |
| 1859 | onTogglePlanSidebar={togglePlanSidebar} | |
| 1860 | /> | |
| 1861 | </> | |
| 1862 | )} | |
| 1863 | </div> | |
| 1864 | ||
| 1865 | {/* Right side: send / stop button */} | |
| 1866 | <div | |
| 1867 | ref={composerFooterActionsRef} | |
| 1868 | data-chat-composer-actions="right" | |
| 1869 | data-chat-composer-primary-actions-compact={ | |
| 1870 | isComposerPrimaryActionsCompact ? "true" : "false" | |
| 1871 | } | |
| 1872 | className="flex shrink-0 flex-nowrap items-center justify-end gap-2" | |
| 1873 | > | |
| 1874 | <ComposerFooterPrimaryActions | |
| 1875 | compact={isComposerPrimaryActionsCompact} | |
| 1876 | activeContextWindow={activeContextWindow} | |
| 1877 | pendingAction={pendingPrimaryAction} | |
| 1878 | isRunning={phase === "running"} | |
| 1879 | showPlanFollowUpPrompt={ | |
| 1880 | pendingUserInputs.length === 0 && showPlanFollowUpPrompt | |
| 1881 | } | |
| 1882 | promptHasText={prompt.trim().length > 0} | |
| 1883 | isSendBusy={isSendBusy} | |
| 1884 | isConnecting={isConnecting} | |
| 1885 | isPreparingWorktree={isPreparingWorktree} | |
| 1886 | hasSendableContent={composerSendState.hasSendableContent} | |
| 1887 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} | |
| 1888 | onInterrupt={handleInterruptPrimaryAction} | |
| 1889 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} | |
| 1890 | /> | |
| 1891 | </div> | |
| 1892 | </div> | |
| 1893 | )} | |
| 1894 | </div> | |
| 1895 | </div> | |
| 1896 | </form> | |
| 1897 | ); | |
| 1898 | }), | |
| 1899 | ); |
apps/web/src/components/chat/ExpandedImageDialog.tsx created+120| ... | ... | @@ -0,0 +1,120 @@ |
| 1 | import { memo, useCallback, useEffect, useState } from "react"; | |
| 2 | import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; | |
| 3 | import { Button } from "../ui/button"; | |
| 4 | import type { ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 5 | ||
| 6 | interface ExpandedImageDialogProps { | |
| 7 | preview: ExpandedImagePreview; | |
| 8 | onClose: () => void; | |
| 9 | } | |
| 10 | ||
| 11 | export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | |
| 12 | preview: initialPreview, | |
| 13 | onClose, | |
| 14 | }: ExpandedImageDialogProps) { | |
| 15 | const [preview, setPreview] = useState(initialPreview); | |
| 16 | ||
| 17 | // Sync when the parent hands us a new preview reference. | |
| 18 | useEffect(() => { | |
| 19 | setPreview(initialPreview); | |
| 20 | }, [initialPreview]); | |
| 21 | ||
| 22 | const navigateImage = useCallback((direction: -1 | 1) => { | |
| 23 | setPreview((existing) => { | |
| 24 | if (existing.images.length <= 1) return existing; | |
| 25 | const nextIndex = | |
| 26 | (existing.index + direction + existing.images.length) % existing.images.length; | |
| 27 | if (nextIndex === existing.index) return existing; | |
| 28 | return { ...existing, index: nextIndex }; | |
| 29 | }); | |
| 30 | }, []); | |
| 31 | ||
| 32 | useEffect(() => { | |
| 33 | const onKeyDown = (event: globalThis.KeyboardEvent) => { | |
| 34 | if (event.key === "Escape") { | |
| 35 | event.preventDefault(); | |
| 36 | event.stopPropagation(); | |
| 37 | onClose(); | |
| 38 | return; | |
| 39 | } | |
| 40 | if (preview.images.length <= 1) return; | |
| 41 | if (event.key === "ArrowLeft") { | |
| 42 | event.preventDefault(); | |
| 43 | event.stopPropagation(); | |
| 44 | navigateImage(-1); | |
| 45 | return; | |
| 46 | } | |
| 47 | if (event.key !== "ArrowRight") return; | |
| 48 | event.preventDefault(); | |
| 49 | event.stopPropagation(); | |
| 50 | navigateImage(1); | |
| 51 | }; | |
| 52 | window.addEventListener("keydown", onKeyDown); | |
| 53 | return () => window.removeEventListener("keydown", onKeyDown); | |
| 54 | }, [navigateImage, onClose, preview.images.length]); | |
| 55 | ||
| 56 | const item = preview.images[preview.index]; | |
| 57 | if (!item) return null; | |
| 58 | ||
| 59 | return ( | |
| 60 | <div | |
| 61 | className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]" | |
| 62 | role="dialog" | |
| 63 | aria-modal="true" | |
| 64 | aria-label="Expanded image preview" | |
| 65 | > | |
| 66 | <button | |
| 67 | type="button" | |
| 68 | className="absolute inset-0 z-0 cursor-zoom-out" | |
| 69 | aria-label="Close image preview" | |
| 70 | onClick={onClose} | |
| 71 | /> | |
| 72 | {preview.images.length > 1 && ( | |
| 73 | <Button | |
| 74 | type="button" | |
| 75 | size="icon" | |
| 76 | variant="ghost" | |
| 77 | className="absolute left-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6" | |
| 78 | aria-label="Previous image" | |
| 79 | onClick={() => navigateImage(-1)} | |
| 80 | > | |
| 81 | <ChevronLeftIcon className="size-5" /> | |
| 82 | </Button> | |
| 83 | )} | |
| 84 | <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]"> | |
| 85 | <Button | |
| 86 | type="button" | |
| 87 | size="icon-xs" | |
| 88 | variant="ghost" | |
| 89 | className="absolute right-2 top-2" | |
| 90 | onClick={onClose} | |
| 91 | aria-label="Close image preview" | |
| 92 | > | |
| 93 | <XIcon /> | |
| 94 | </Button> | |
| 95 | <img | |
| 96 | src={item.src} | |
| 97 | alt={item.name} | |
| 98 | className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl" | |
| 99 | draggable={false} | |
| 100 | /> | |
| 101 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> | |
| 102 | {item.name} | |
| 103 | {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""} | |
| 104 | </p> | |
| 105 | </div> | |
| 106 | {preview.images.length > 1 && ( | |
| 107 | <Button | |
| 108 | type="button" | |
| 109 | size="icon" | |
| 110 | variant="ghost" | |
| 111 | className="absolute right-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6" | |
| 112 | aria-label="Next image" | |
| 113 | onClick={() => navigateImage(1)} | |
| 114 | > | |
| 115 | <ChevronRightIcon className="size-5" /> | |
| 116 | </Button> | |
| 117 | )} | |
| 118 | </div> | |
| 119 | ); | |
| 120 | }); |
apps/web/src/composerDraftStore.ts+29-1| ... | ... | @@ -38,6 +38,7 @@ import { |
| 38 | 38 | } from "./lib/terminalContext"; |
| 39 | 39 | import { create } from "zustand"; |
| 40 | 40 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 41 | import { useShallow } from "zustand/react/shallow"; | |
| 41 | 42 | import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; |
| 42 | 43 | import { getDefaultServerModel } from "./providerModels"; |
| 43 | 44 | import { UnifiedSettings } from "@t3tools/contracts/settings"; |
| ... | ... | @@ -378,6 +379,11 @@ export interface EffectiveComposerModelState { |
| 378 | 379 | modelOptions: ProviderModelOptions | null; |
| 379 | 380 | } |
| 380 | 381 | |
| 382 | interface ComposerDraftModelState { | |
| 383 | activeProvider: ProviderKind | null; | |
| 384 | modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; | |
| 385 | } | |
| 386 | ||
| 381 | 387 | function providerModelOptionsFromSelection( |
| 382 | 388 | modelSelection: ModelSelection | null | undefined, |
| 383 | 389 | ): ProviderModelOptions | null { |
| ... | ... | @@ -420,6 +426,10 @@ Object.freeze(EMPTY_IDS); |
| 420 | 426 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); |
| 421 | 427 | const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderKind, ModelSelection>> = |
| 422 | 428 | Object.freeze({}); |
| 429 | const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>({ | |
| 430 | activeProvider: null, | |
| 431 | modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, | |
| 432 | }); | |
| 423 | 433 | |
| 424 | 434 | const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({ |
| 425 | 435 | prompt: "", |
| ... | ... | @@ -2764,6 +2774,22 @@ export function useComposerThreadDraft(threadRef: ComposerThreadTarget): Compose |
| 2764 | 2774 | }); |
| 2765 | 2775 | } |
| 2766 | 2776 | |
| 2777 | export function useComposerDraftModelState( | |
| 2778 | threadRef: ComposerThreadTarget, | |
| 2779 | ): ComposerDraftModelState { | |
| 2780 | return useComposerDraftStore( | |
| 2781 | useShallow((state) => { | |
| 2782 | const draft = getComposerDraftState(state, threadRef); | |
| 2783 | return draft | |
| 2784 | ? { | |
| 2785 | activeProvider: draft.activeProvider, | |
| 2786 | modelSelectionByProvider: draft.modelSelectionByProvider, | |
| 2787 | } | |
| 2788 | : EMPTY_COMPOSER_DRAFT_MODEL_STATE; | |
| 2789 | }), | |
| 2790 | ); | |
| 2791 | } | |
| 2792 | ||
| 2767 | 2793 | export function useEffectiveComposerModelState(input: { |
| 2768 | 2794 | threadRef?: ComposerThreadTarget; |
| 2769 | 2795 | draftId?: DraftId; |
| ... | ... | @@ -2773,7 +2799,9 @@ export function useEffectiveComposerModelState(input: { |
| 2773 | 2799 | projectModelSelection: ModelSelection | null | undefined; |
| 2774 | 2800 | settings: UnifiedSettings; |
| 2775 | 2801 | }): EffectiveComposerModelState { |
| 2776 | const draft = useComposerThreadDraft(input.threadRef ?? input.draftId ?? DraftId.makeUnsafe("")); | |
| 2802 | const draft = useComposerDraftModelState( | |
| 2803 | input.threadRef ?? input.draftId ?? DraftId.makeUnsafe(""), | |
| 2804 | ); | |
| 2777 | 2805 | |
| 2778 | 2806 | return useMemo( |
| 2779 | 2807 | () => |
apps/web/src/hooks/useHandleNewThread.ts+39-26| ... | ... | @@ -16,39 +16,15 @@ import { createThreadSelectorByRef } from "../storeSelectors"; |
| 16 | 16 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 17 | 17 | import { useUiStateStore } from "../uiStateStore"; |
| 18 | 18 | |
| 19 | export function useHandleNewThread() { | |
| 19 | function useNewThreadState() { | |
| 20 | 20 | const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); |
| 21 | const projectOrder = useUiStateStore((store) => store.projectOrder); | |
| 22 | 21 | const router = useRouter(); |
| 23 | const routeTarget = useParams({ | |
| 24 | strict: false, | |
| 25 | select: (params) => resolveThreadRouteTarget(params), | |
| 26 | }); | |
| 27 | const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; | |
| 28 | const activeThread = useStore( | |
| 29 | useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), | |
| 30 | ); | |
| 31 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); | |
| 32 | const activeDraftThread = useComposerDraftStore(() => | |
| 33 | routeTarget | |
| 34 | ? routeTarget.kind === "server" | |
| 35 | ? getDraftThread(routeTarget.threadRef) | |
| 36 | : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) | |
| 37 | : null, | |
| 38 | ); | |
| 39 | const orderedProjects = useMemo(() => { | |
| 40 | return orderItemsByPreferredIds({ | |
| 41 | items: projects, | |
| 42 | preferredIds: projectOrder, | |
| 43 | getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 44 | }); | |
| 45 | }, [projectOrder, projects]); | |
| 46 | 22 | const getCurrentRouteTarget = useCallback(() => { |
| 47 | 23 | const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; |
| 48 | 24 | return resolveThreadRouteTarget(currentRouteParams); |
| 49 | 25 | }, [router]); |
| 50 | 26 | |
| 51 | const handleNewThread = useCallback( | |
| 27 | return useCallback( | |
| 52 | 28 | ( |
| 53 | 29 | projectRef: ScopedProjectRef, |
| 54 | 30 | options?: { |
| ... | ... | @@ -154,6 +130,43 @@ export function useHandleNewThread() { |
| 154 | 130 | }, |
| 155 | 131 | [getCurrentRouteTarget, router, projects], |
| 156 | 132 | ); |
| 133 | } | |
| 134 | ||
| 135 | export function useNewThreadHandler() { | |
| 136 | const handleNewThread = useNewThreadState(); | |
| 137 | ||
| 138 | return { | |
| 139 | handleNewThread, | |
| 140 | }; | |
| 141 | } | |
| 142 | ||
| 143 | export function useHandleNewThread() { | |
| 144 | const projectOrder = useUiStateStore((store) => store.projectOrder); | |
| 145 | const routeTarget = useParams({ | |
| 146 | strict: false, | |
| 147 | select: (params) => resolveThreadRouteTarget(params), | |
| 148 | }); | |
| 149 | const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; | |
| 150 | const activeThread = useStore( | |
| 151 | useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), | |
| 152 | ); | |
| 153 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); | |
| 154 | const activeDraftThread = useComposerDraftStore(() => | |
| 155 | routeTarget | |
| 156 | ? routeTarget.kind === "server" | |
| 157 | ? getDraftThread(routeTarget.threadRef) | |
| 158 | : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) | |
| 159 | : null, | |
| 160 | ); | |
| 161 | const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); | |
| 162 | const orderedProjects = useMemo(() => { | |
| 163 | return orderItemsByPreferredIds({ | |
| 164 | items: projects, | |
| 165 | preferredIds: projectOrder, | |
| 166 | getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 167 | }); | |
| 168 | }, [projectOrder, projects]); | |
| 169 | const handleNewThread = useNewThreadState(); | |
| 157 | 170 | |
| 158 | 171 | return { |
| 159 | 172 | activeDraftThread, |
apps/web/src/hooks/useThreadActions.ts+2-2| ... | ... | @@ -6,7 +6,7 @@ import { useCallback } from "react"; |
| 6 | 6 | |
| 7 | 7 | import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; |
| 8 | 8 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 9 | import { useHandleNewThread } from "./useHandleNewThread"; | |
| 9 | import { useNewThreadHandler } from "./useHandleNewThread"; | |
| 10 | 10 | import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; |
| 11 | 11 | import { invalidateGitQueries } from "../lib/gitReactQuery"; |
| 12 | 12 | import { newCommandId } from "../lib/utils"; |
| ... | ... | @@ -32,7 +32,7 @@ export function useThreadActions() { |
| 32 | 32 | ); |
| 33 | 33 | const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); |
| 34 | 34 | const router = useRouter(); |
| 35 | const { handleNewThread } = useHandleNewThread(); | |
| 35 | const { handleNewThread } = useNewThreadHandler(); | |
| 36 | 36 | const queryClient = useQueryClient(); |
| 37 | 37 | |
| 38 | 38 | const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { |