import { RefreshIcon } from "~/components/ui/refresh-icon";import { questionAttachmentDraftId, useQuestionAttachmentPreparation, changeQuestionAttachmentPreparation,} from "../../questionAttachments";import type { ApprovalRequestId, AssistantCitation, ChatFileAttachment, EnvironmentId, ModelSelection, PreviewAnnotationPayload, ProviderApprovalDecision, ProviderInteractionMode, ResolvedKeybindingsConfig, RuntimeMode, ScopedThreadRef, ServerProvider, ThreadId, SnapShotSource,} from "@t3tools/contracts";import { ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,} from "@t3tools/contracts";import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";import { Fragment, memo, type ComponentProps, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore,} from "react";import { createPortal } from "react-dom";import { clampCollapsedComposerCursor, type ComposerSubmissionIntent, type ComposerTrigger, collapseExpandedComposerCursor, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, formatAssistantCitationForComposer, replaceTextRange,} from "../../composer-logic";import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";import { deriveComposerSendState, getAntigravitySendBlockReason, readFileAsDataUrl, resolveComposerInteractionMode, resolveComposerProviderSelection, threadShellHasStarted,} from "../ChatView.logic";import { dataTransferHasComposerMention, makeComposerMentionDragHandlers,} from "./composerMentionDrag";import { composerFloatingLayerProps, isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope,} from "./composerEventScope";import { type ComposerFileAttachment, type ComposerImageAttachment, type DraftId, type PersistedComposerFileAttachment, type PersistedComposerImageAttachment, composerFileDedupKey, composerFileMatchesReattachMarker, composerFileNeedsReattach, composerTargetKey, hydrateImagesFromPersisted, useComposerDraftStore, useComposerThreadDraft, useEffectiveComposerModelState,} from "../../composerDraftStore";import { MAX_STASH_ENTRIES, partitionStashAttachments, usePromptStashStore, type PromptStashEntry,} from "../../promptStashStore";import { ComposerStashBadge } from "./ComposerStashBadge";import { ComposerStashMenu } from "./ComposerStashMenu";import { useComposerMenuState } from "./useComposerMenuState";import { useComposerFocusState } from "./useComposerFocusState";import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt";import { ComposerTasksBadge, ComposerTasksContent, ComposerTasksDrawer, type ComposerTaskStep, type ComposerTasksProgress,} from "./ComposerTasksBadge";import { ComposerActivityRow } from "./ComposerActivityStatus";import type { ThreadSyncPhase } from "../../threadSync";import { ComposerBanner } from "./ComposerBanner";import { ComposerSurface } from "./ComposerSurface";import { ComposerBannerStack, type ComposerBannerStackContent, type ComposerBannerStackItem,} from "./ComposerBannerStack";import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";import { fileAttachmentTooLargeMessage, formatAttachmentSize,} from "@t3tools/client-runtime/state/attachments";import { attachmentsToReleaseOnUploadCapabilityLoss, classifyComposerAttachmentFile, fileAttachmentCapabilityBlockReason, fileAttachmentStagingLimit, isPreviewableComposerVideo, normalizeComposerImageFileMimeType, shouldHandleComposerAttachmentPaste,} from "./composerAttachmentFiles";import { readAttachmentUpload, releaseAttachmentUpload, releaseDraftAttachment, releasePersistedAttachmentUpload, retryAttachmentUpload, startAttachmentUpload, useAttachmentUploadStore, verifyStashedAttachmentUpload,} from "../../lib/attachmentUploadQueue";import { attachmentUploadBlockReason, formatAttachmentUploadProgress,} from "../../lib/attachmentUploadState";import { isCommandPaletteOpen } from "../../commandPaletteBus";import { getTerminalFocusOwner } from "../../lib/terminalFocus";import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings";import { type TerminalContextDraft, type TerminalContextSelection, INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder,} from "../../lib/terminalContext";import { useComposerPathSearch } from "../../lib/composerPathSearchState";import { type ElementContextDraft } from "../../lib/elementContext";import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts";import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments";import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards";import { COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, getRestingComposerImagePreviewCounts, resolveRestingComposerControlsLayout, shouldAnimateComposerRestingTransition, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, shouldUseRestingComposerLayout,} from "../composerFooterLayout";import { measureRestingComposerControls } from "./restingComposerControlsMeasurement";import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";import { ProviderModelPicker } from "./ProviderModelPicker";import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";import { ComposerPrimaryActions } from "./ComposerPrimaryActions";import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";import { ComposerControl, ComposerControlIcon, ComposerControlSeparator, ComposerSelectControl,} from "./ComposerControl";import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";import { searchSlashCommandItems, slashCommandItemsForPromptPosition,} from "./composerSlashCommandSearch";import { getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker,} from "./composerProviderState";import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter";import { providerSupportsManualCompaction, resolveContextWindowModelDisplayName, shouldReserveContextWindowMeter,} from "./ContextWindowMeter.logic";import { attachVideoThumbnail, buildExpandedImagePreview, type ExpandedImagePreview,} from "./ExpandedImagePreview";import { SNAP_SHOT_ATTACHMENT_FRAME_CLASS, SnapShotAttachmentDetails,} from "./SnapShotAttachmentDetails";import { getPendingSnapShotAnimations, pendingSnapShotAnimationIdsForTarget, scheduleSnapShotAnimationDestination, setSnapShotAnimationDestination, shouldAnimateSnapShotArrival, subscribeToPendingSnapShotAnimations,} from "../../lib/snapShotAnimation";import { resizeSnapShotSource } from "../../lib/snapShotSource";import { basenameOfPath } from "../../pierre-icons";import { cn, randomUUID } from "~/lib/utils";import { getComposerPromptLengthValidationMessage, getComposerSubmissionValidationMessage, submitComposerDraft,} from "./composerSubmission";import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";import { PierreEntryIcon } from "./PierreEntryIcon";import { createComposerScrollGestureState, recordComposerScrollGestureEvent, shouldCollapseComposerForScrollKey, resetComposerScrollGesture, suppressActiveComposerScrollGesture,} from "./composerScrollGesture";import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = useCallback( (video: HTMLVideoElement | null) => { if (!video) return; return attachVideoThumbnail(video, file); }, [file], ); return ( <video ref={setVideo} muted playsInline preload="metadata" aria-hidden="true" onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)} className="pointer-events-none absolute inset-0 size-full object-cover" /> );} type ComposerCommandMenuPosition = { bottom: number; left: number; maxHeight: number; width: number;}; function SnapShotAttachmentFrame({ animationId, animationSource, arrival, animateArrival, className, ...props}: ComponentProps<"div"> & { readonly animationId?: string | undefined; readonly animationSource?: SnapShotSource | undefined; readonly arrival?: boolean | undefined; readonly animateArrival?: boolean | undefined;}) { const frameRef = useRef<HTMLDivElement>(null); useLayoutEffect(() => { const frame = frameRef.current; if (!frame || (!animationId && !arrival)) return; frame.scrollIntoView({ block: "nearest", inline: "nearest" }); if (!animationId) return; return scheduleSnapShotAnimationDestination(animationId, () => setSnapShotAnimationDestination(animationId, frame, animationSource), ); }, [animationId, animationSource, arrival]); return ( <div ref={frameRef} className={cn( animateArrival && !animationId && "origin-center transition-[opacity,scale] duration-300 ease-[cubic-bezier(.2,.8,.2,1)] starting:scale-95 starting:opacity-0 motion-reduce:transition-none motion-reduce:starting:scale-100 motion-reduce:starting:opacity-100", className, )} {...props} /> );} const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24;const COMPOSER_SCROLL_GESTURE_RESET_MS = 120;const COMPOSER_RESTING_TRANSITION_DURATION_MS = 280;const COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS = 50;const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; function useComposerRestingTransition( isCollapsed: boolean, isResting: boolean, restingControlsRef: React.RefObject<HTMLDivElement | null>, onOverlayHeightChange: (height: number) => void,) { const elementRef = useRef<HTMLDivElement>(null); const isCollapsedRef = useRef(isCollapsed); const previousCollapsedRef = useRef(isCollapsed); const previousRestingRef = useRef(isResting); const previousHeightRef = useRef<number | null>(null); const previousContentOffsetsRef = useRef<{ promptFromTop: number | null; promptHeight: number | null; actionFromBottom: number | null; }>({ promptFromTop: null, promptHeight: null, actionFromBottom: null }); const animationRef = useRef<Animation | null>(null); const animationTargetHeightRef = useRef<number | null>(null); const contentAnimationsRef = useRef<Animation[]>([]); const stateChangeAnimationsRef = useRef<Animation[]>([]); const pinnedOverlayRef = useRef<HTMLElement | null>(null); const transitionCleanupTimeoutRef = useRef<number | null>(null); const transitionLayoutRequestRef = useRef(0); const hasCompletedInitialLayoutRef = useRef(false); const clearOverlayPin = useCallback(() => { // The overlay belongs to the chat view and outlives this composer, so it // is remembered from pin time rather than re-resolved through a ref that // React may already have detached during unmount. const overlay = pinnedOverlayRef.current; pinnedOverlayRef.current = null; overlay?.style.removeProperty("height"); overlay?.style.removeProperty("display"); overlay?.style.removeProperty("flex-direction"); overlay?.style.removeProperty("justify-content"); }, []); const clearTransitionStyles = useCallback(() => { const element = elementRef.current; const footer = element?.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'); element?.style.removeProperty("overflow"); element ?.querySelector<HTMLElement>('[data-chat-composer-surface="true"]') ?.style.removeProperty("height"); footer?.style.removeProperty("position"); footer?.style.removeProperty("top"); footer?.style.removeProperty("bottom"); footer?.style.removeProperty("left"); footer?.style.removeProperty("right"); footer?.style.removeProperty("height"); clearOverlayPin(); }, [clearOverlayPin]); isCollapsedRef.current = isCollapsed; const transitionToCurrentGeometry = useCallback( (stateChanged: boolean) => { const element = elementRef.current; const surface = element?.querySelector<HTMLElement>('[data-chat-composer-surface="true"]'); if (!element || !surface) return; const nextIsCollapsed = isCollapsedRef.current; const visibleTransitionElement = (selector: string) => Array.from(element.querySelectorAll<HTMLElement>(selector)).find( (candidate) => candidate.getClientRects().length > 0, ) ?? null; const prompt = visibleTransitionElement( '[data-testid="composer-editor"], [data-chat-composer-transition-prompt="true"]', ); const action = visibleTransitionElement('[data-chat-composer-transition-actions="true"]'); const footer = element.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'); const interruptedAnimation = animationRef.current; const interruptedPromptTop = interruptedAnimation ? (prompt?.getBoundingClientRect().top ?? null) : null; const interruptedActionTop = interruptedAnimation ? (action?.getBoundingClientRect().top ?? null) : null; const interruptedHeight = interruptedAnimation ? element.getBoundingClientRect().height : null; const interruptedTargetHeight = animationTargetHeightRef.current; const interruptedCurrentTime = typeof interruptedAnimation?.currentTime === "number" ? interruptedAnimation.currentTime : null; const interruptedDuration = interruptedAnimation?.effect?.getComputedTiming().duration; if (transitionCleanupTimeoutRef.current !== null) { window.clearTimeout(transitionCleanupTimeoutRef.current); transitionCleanupTimeoutRef.current = null; } interruptedAnimation?.cancel(); animationRef.current = null; for (const animation of contentAnimationsRef.current) animation.cancel(); contentAnimationsRef.current = []; // The reveal and fade animations keep their own schedule across the // body-resize re-entries that retarget the geometry mid-flight (every // transition with a draft triggers one); cancelling them there would // pop their subjects to full visibility at the start of the tween. if (stateChanged) { for (const animation of stateChangeAnimationsRef.current) animation.cancel(); stateChangeAnimationsRef.current = []; } clearTransitionStyles(); const nextRect = element.getBoundingClientRect(); const nextHeight = nextRect.height; // The chat view resize-observes the overlay to place the timeline // inset, the scroll-to-end pill, and the mini player. Publishing the // destination height here turns that feedback into one update instead // of a ChatView re-render on every animation frame. const overlay = element.closest<HTMLElement>('[data-chat-composer-overlay="true"]'); const overlayHeight = overlay?.getBoundingClientRect().height ?? null; if (overlayHeight !== null) { onOverlayHeightChange(overlayHeight); } const nextPromptRect = prompt?.getBoundingClientRect() ?? null; const nextPromptTop = nextPromptRect?.top ?? null; const nextActionTop = action?.getBoundingClientRect().top ?? null; const previousHeight = interruptedHeight ?? previousHeightRef.current; const targetChanged = interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5; const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; const shouldAnimate = shouldAnimateComposerRestingTransition({ hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current, stateChanged, hasInterruptedAnimation: interruptedHeight !== null, }); if ( shouldAnimate && !prefersReducedMotion && previousHeight !== null && Math.abs(previousHeight - nextHeight) >= 0.5 ) { const remainingDuration = typeof interruptedDuration === "number" && interruptedCurrentTime !== null ? Math.max(1, interruptedDuration - interruptedCurrentTime) : COMPOSER_RESTING_TRANSITION_DURATION_MS; const duration = interruptedHeight !== null && !targetChanged ? remainingDuration : COMPOSER_RESTING_TRANSITION_DURATION_MS; element.style.overflow = "clip"; surface.style.height = "100%"; // Pinning the overlay at the destination height keeps the resize // observer quiet for the tween; bottom alignment keeps the animating // surface glued to the overlay's stable bottom edge. The pin lasts // only for the tween so later attachment, thread, font, and viewport // changes remain natural. if (overlay && overlayHeight !== null) { overlay.style.height = `${String(overlayHeight)}px`; overlay.style.display = "flex"; overlay.style.flexDirection = "column"; overlay.style.justifyContent = "flex-end"; pinnedOverlayRef.current = overlay; } // Keep the footer attached to the stable bottom edge while the outer // height changes. Its resting absolute layout otherwise spans the old // height on collapse, while its expanded flow layout falls below the // clipped surface on expansion. if (footer) { footer.style.position = "absolute"; footer.style.top = "auto"; footer.style.bottom = "1px"; footer.style.height = "3rem"; if (nextIsCollapsed) { footer.style.left = "auto"; footer.style.right = "1px"; } else { footer.style.left = "1px"; footer.style.right = "1px"; } } const animation = element.animate( [{ height: `${previousHeight}px` }, { height: `${nextHeight}px` }], {Lines 1–500 of 5889. Load more
import { RefreshIcon } from "~/components/ui/refresh-icon"; import { questionAttachmentDraftId, useQuestionAttachmentPreparation, changeQuestionAttachmentPreparation, } from "../../questionAttachments"; import type { ApprovalRequestId, AssistantCitation, ChatFileAttachment, EnvironmentId, ModelSelection, PreviewAnnotationPayload, ProviderApprovalDecision, ProviderInteractionMode, ResolvedKeybindingsConfig, RuntimeMode, ScopedThreadRef, ServerProvider, ThreadId, SnapShotSource, } from "@t3tools/contracts"; import { ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { Fragment, memo, type ComponentProps, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { createPortal } from "react-dom"; import { clampCollapsedComposerCursor, type ComposerSubmissionIntent, type ComposerTrigger, collapseExpandedComposerCursor, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, formatAssistantCitationForComposer, replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { deriveComposerSendState, getAntigravitySendBlockReason, readFileAsDataUrl, resolveComposerInteractionMode, resolveComposerProviderSelection, threadShellHasStarted, } from "../ChatView.logic"; import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, } from "./composerMentionDrag"; import { composerFloatingLayerProps, isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, } from "./composerEventScope"; import { type ComposerFileAttachment, type ComposerImageAttachment, type DraftId, type PersistedComposerFileAttachment, type PersistedComposerImageAttachment, composerFileDedupKey, composerFileMatchesReattachMarker, composerFileNeedsReattach, composerTargetKey, hydrateImagesFromPersisted, useComposerDraftStore, useComposerThreadDraft, useEffectiveComposerModelState, } from "../../composerDraftStore"; import { MAX_STASH_ENTRIES, partitionStashAttachments, usePromptStashStore, type PromptStashEntry, } from "../../promptStashStore"; import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerMenuState } from "./useComposerMenuState"; import { useComposerFocusState } from "./useComposerFocusState"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; import { ComposerTasksBadge, ComposerTasksContent, ComposerTasksDrawer, type ComposerTaskStep, type ComposerTasksProgress, } from "./ComposerTasksBadge"; import { ComposerActivityRow } from "./ComposerActivityStatus"; import type { ThreadSyncPhase } from "../../threadSync"; import { ComposerBanner } from "./ComposerBanner"; import { ComposerSurface } from "./ComposerSurface"; import { ComposerBannerStack, type ComposerBannerStackContent, type ComposerBannerStackItem, } from "./ComposerBannerStack"; import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression"; import { fileAttachmentTooLargeMessage, formatAttachmentSize, } from "@t3tools/client-runtime/state/attachments"; import { attachmentsToReleaseOnUploadCapabilityLoss, classifyComposerAttachmentFile, fileAttachmentCapabilityBlockReason, fileAttachmentStagingLimit, isPreviewableComposerVideo, normalizeComposerImageFileMimeType, shouldHandleComposerAttachmentPaste, } from "./composerAttachmentFiles"; import { readAttachmentUpload, releaseAttachmentUpload, releaseDraftAttachment, releasePersistedAttachmentUpload, retryAttachmentUpload, startAttachmentUpload, useAttachmentUploadStore, verifyStashedAttachmentUpload, } from "../../lib/attachmentUploadQueue"; import { attachmentUploadBlockReason, formatAttachmentUploadProgress, } from "../../lib/attachmentUploadState"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings"; import { type TerminalContextDraft, type TerminalContextSelection, INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { type ElementContextDraft } from "../../lib/elementContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards"; import { COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, getRestingComposerImagePreviewCounts, resolveRestingComposerControlsLayout, shouldAnimateComposerRestingTransition, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, shouldUseRestingComposerLayout, } from "../composerFooterLayout"; import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { ComposerControl, ComposerControlIcon, ComposerControlSeparator, ComposerSelectControl, } from "./ComposerControl"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems, slashCommandItemsForPromptPosition, } from "./composerSlashCommandSearch"; import { getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter"; import { providerSupportsManualCompaction, resolveContextWindowModelDisplayName, shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; import { attachVideoThumbnail, buildExpandedImagePreview, type ExpandedImagePreview, } from "./ExpandedImagePreview"; import { SNAP_SHOT_ATTACHMENT_FRAME_CLASS, SnapShotAttachmentDetails, } from "./SnapShotAttachmentDetails"; import { getPendingSnapShotAnimations, pendingSnapShotAnimationIdsForTarget, scheduleSnapShotAnimationDestination, setSnapShotAnimationDestination, shouldAnimateSnapShotArrival, subscribeToPendingSnapShotAnimations, } from "../../lib/snapShotAnimation"; import { resizeSnapShotSource } from "../../lib/snapShotSource"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; import { getComposerPromptLengthValidationMessage, getComposerSubmissionValidationMessage, submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { createComposerScrollGestureState, recordComposerScrollGestureEvent, shouldCollapseComposerForScrollKey, resetComposerScrollGesture, suppressActiveComposerScrollGesture, } from "./composerScrollGesture"; import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = useCallback( (video: HTMLVideoElement | null) => { if (!video) return; return attachVideoThumbnail(video, file); }, [file], ); return ( <video ref={setVideo} muted playsInline preload="metadata" aria-hidden="true" onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)} className="pointer-events-none absolute inset-0 size-full object-cover" /> ); } type ComposerCommandMenuPosition = { bottom: number; left: number; maxHeight: number; width: number; }; function SnapShotAttachmentFrame({ animationId, animationSource, arrival, animateArrival, className, ...props }: ComponentProps<"div"> & { readonly animationId?: string | undefined; readonly animationSource?: SnapShotSource | undefined; readonly arrival?: boolean | undefined; readonly animateArrival?: boolean | undefined; }) { const frameRef = useRef<HTMLDivElement>(null); useLayoutEffect(() => { const frame = frameRef.current; if (!frame || (!animationId && !arrival)) return; frame.scrollIntoView({ block: "nearest", inline: "nearest" }); if (!animationId) return; return scheduleSnapShotAnimationDestination(animationId, () => setSnapShotAnimationDestination(animationId, frame, animationSource), ); }, [animationId, animationSource, arrival]); return ( <div ref={frameRef} className={cn( animateArrival && !animationId && "origin-center transition-[opacity,scale] duration-300 ease-[cubic-bezier(.2,.8,.2,1)] starting:scale-95 starting:opacity-0 motion-reduce:transition-none motion-reduce:starting:scale-100 motion-reduce:starting:opacity-100", className, )} {...props} /> ); } const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; const COMPOSER_RESTING_TRANSITION_DURATION_MS = 280; const COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS = 50; const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; function useComposerRestingTransition( isCollapsed: boolean, isResting: boolean, restingControlsRef: React.RefObject<HTMLDivElement | null>, onOverlayHeightChange: (height: number) => void, ) { const elementRef = useRef<HTMLDivElement>(null); const isCollapsedRef = useRef(isCollapsed); const previousCollapsedRef = useRef(isCollapsed); const previousRestingRef = useRef(isResting); const previousHeightRef = useRef<number | null>(null); const previousContentOffsetsRef = useRef<{ promptFromTop: number | null; promptHeight: number | null; actionFromBottom: number | null; }>({ promptFromTop: null, promptHeight: null, actionFromBottom: null }); const animationRef = useRef<Animation | null>(null); const animationTargetHeightRef = useRef<number | null>(null); const contentAnimationsRef = useRef<Animation[]>([]); const stateChangeAnimationsRef = useRef<Animation[]>([]); const pinnedOverlayRef = useRef<HTMLElement | null>(null); const transitionCleanupTimeoutRef = useRef<number | null>(null); const transitionLayoutRequestRef = useRef(0); const hasCompletedInitialLayoutRef = useRef(false); const clearOverlayPin = useCallback(() => { // The overlay belongs to the chat view and outlives this composer, so it // is remembered from pin time rather than re-resolved through a ref that // React may already have detached during unmount. const overlay = pinnedOverlayRef.current; pinnedOverlayRef.current = null; overlay?.style.removeProperty("height"); overlay?.style.removeProperty("display"); overlay?.style.removeProperty("flex-direction"); overlay?.style.removeProperty("justify-content"); }, []); const clearTransitionStyles = useCallback(() => { const element = elementRef.current; const footer = element?.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'); element?.style.removeProperty("overflow"); element ?.querySelector<HTMLElement>('[data-chat-composer-surface="true"]') ?.style.removeProperty("height"); footer?.style.removeProperty("position"); footer?.style.removeProperty("top"); footer?.style.removeProperty("bottom"); footer?.style.removeProperty("left"); footer?.style.removeProperty("right"); footer?.style.removeProperty("height"); clearOverlayPin(); }, [clearOverlayPin]); isCollapsedRef.current = isCollapsed; const transitionToCurrentGeometry = useCallback( (stateChanged: boolean) => { const element = elementRef.current; const surface = element?.querySelector<HTMLElement>('[data-chat-composer-surface="true"]'); if (!element || !surface) return; const nextIsCollapsed = isCollapsedRef.current; const visibleTransitionElement = (selector: string) => Array.from(element.querySelectorAll<HTMLElement>(selector)).find( (candidate) => candidate.getClientRects().length > 0, ) ?? null; const prompt = visibleTransitionElement( '[data-testid="composer-editor"], [data-chat-composer-transition-prompt="true"]', ); const action = visibleTransitionElement('[data-chat-composer-transition-actions="true"]'); const footer = element.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'); const interruptedAnimation = animationRef.current; const interruptedPromptTop = interruptedAnimation ? (prompt?.getBoundingClientRect().top ?? null) : null; const interruptedActionTop = interruptedAnimation ? (action?.getBoundingClientRect().top ?? null) : null; const interruptedHeight = interruptedAnimation ? element.getBoundingClientRect().height : null; const interruptedTargetHeight = animationTargetHeightRef.current; const interruptedCurrentTime = typeof interruptedAnimation?.currentTime === "number" ? interruptedAnimation.currentTime : null; const interruptedDuration = interruptedAnimation?.effect?.getComputedTiming().duration; if (transitionCleanupTimeoutRef.current !== null) { window.clearTimeout(transitionCleanupTimeoutRef.current); transitionCleanupTimeoutRef.current = null; } interruptedAnimation?.cancel(); animationRef.current = null; for (const animation of contentAnimationsRef.current) animation.cancel(); contentAnimationsRef.current = []; // The reveal and fade animations keep their own schedule across the // body-resize re-entries that retarget the geometry mid-flight (every // transition with a draft triggers one); cancelling them there would // pop their subjects to full visibility at the start of the tween. if (stateChanged) { for (const animation of stateChangeAnimationsRef.current) animation.cancel(); stateChangeAnimationsRef.current = []; } clearTransitionStyles(); const nextRect = element.getBoundingClientRect(); const nextHeight = nextRect.height; // The chat view resize-observes the overlay to place the timeline // inset, the scroll-to-end pill, and the mini player. Publishing the // destination height here turns that feedback into one update instead // of a ChatView re-render on every animation frame. const overlay = element.closest<HTMLElement>('[data-chat-composer-overlay="true"]'); const overlayHeight = overlay?.getBoundingClientRect().height ?? null; if (overlayHeight !== null) { onOverlayHeightChange(overlayHeight); } const nextPromptRect = prompt?.getBoundingClientRect() ?? null; const nextPromptTop = nextPromptRect?.top ?? null; const nextActionTop = action?.getBoundingClientRect().top ?? null; const previousHeight = interruptedHeight ?? previousHeightRef.current; const targetChanged = interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5; const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; const shouldAnimate = shouldAnimateComposerRestingTransition({ hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current, stateChanged, hasInterruptedAnimation: interruptedHeight !== null, }); if ( shouldAnimate && !prefersReducedMotion && previousHeight !== null && Math.abs(previousHeight - nextHeight) >= 0.5 ) { const remainingDuration = typeof interruptedDuration === "number" && interruptedCurrentTime !== null ? Math.max(1, interruptedDuration - interruptedCurrentTime) : COMPOSER_RESTING_TRANSITION_DURATION_MS; const duration = interruptedHeight !== null && !targetChanged ? remainingDuration : COMPOSER_RESTING_TRANSITION_DURATION_MS; element.style.overflow = "clip"; surface.style.height = "100%"; // Pinning the overlay at the destination height keeps the resize // observer quiet for the tween; bottom alignment keeps the animating // surface glued to the overlay's stable bottom edge. The pin lasts // only for the tween so later attachment, thread, font, and viewport // changes remain natural. if (overlay && overlayHeight !== null) { overlay.style.height = `${String(overlayHeight)}px`; overlay.style.display = "flex"; overlay.style.flexDirection = "column"; overlay.style.justifyContent = "flex-end"; pinnedOverlayRef.current = overlay; } // Keep the footer attached to the stable bottom edge while the outer // height changes. Its resting absolute layout otherwise spans the old // height on collapse, while its expanded flow layout falls below the // clipped surface on expansion. if (footer) { footer.style.position = "absolute"; footer.style.top = "auto"; footer.style.bottom = "1px"; footer.style.height = "3rem"; if (nextIsCollapsed) { footer.style.left = "auto"; footer.style.right = "1px"; } else { footer.style.left = "1px"; footer.style.right = "1px"; } } const animation = element.animate( [{ height: `${previousHeight}px` }, { height: `${nextHeight}px` }], {