diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 18eea30efb31..03c9a4987543 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -41,7 +41,6 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, - PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -67,7 +66,6 @@ import { useLayoutEffect, useRef, useState, - useSyncExternalStore, } from "react"; import { createPortal, flushSync } from "react-dom"; import { @@ -91,7 +89,6 @@ import { getAntigravitySendBlockReason, readFileAsDataUrl, resolveComposerInteractionMode, - resolveComposerProviderSelection, threadShellHasStarted, } from "../ChatView.logic"; import { @@ -116,8 +113,6 @@ import { hydrateImagesFromPersisted, useComposerDraftStore, useComposerThreadDraft, - deriveEffectiveComposerModelState, - useComposerDraftModelState, } from "../../composerDraftStore"; import { MAX_STASH_ENTRIES, @@ -189,13 +184,10 @@ import { toKindScopedComposerContextId, } from "@t3tools/shared/composerContextReferences"; import { - resolveRestingComposerControlsLayout, - shouldAnimateComposerRestingTransition, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, shouldUseRestingComposerLayout, } from "../composerFooterLayout"; -import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; import { type ComposerCitationCommentRequest, type ComposerPromptEditorHandle, @@ -285,7 +277,6 @@ import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDetail.logic"; import { getComposerPromptInjectionState, - getComposerProviderState, getComposerReasoningStep, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -301,11 +292,6 @@ import { buildExpandedImagePreview, type ExpandedImagePreview, } from "./ExpandedImagePreview"; -import { - getPendingSnapShotAnimations, - pendingSnapShotAnimationIdsForTarget, - subscribeToPendingSnapShotAnimations, -} from "../../lib/snapShotAnimation"; import { resizeSnapShotSource } from "../../lib/snapShotSource"; import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { @@ -329,631 +315,14 @@ import { } from "./composerScrollGesture"; import { observeResize } from "~/lib/observeResize"; -type ComposerCommandMenuPosition = { - bottom: number; - left: number; - maxHeight: number; - width: number; -}; - const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; -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, - onOverlayHeightChange: (height: number) => void, - animationsActive: boolean, - animationDurationMs: number, -) { - const elementRef = useRef(null); - const isCollapsedRef = useRef(isCollapsed); - const previousCollapsedRef = useRef(isCollapsed); - const previousRestingRef = useRef(isResting); - const previousHeightRef = useRef(null); - const previousModelStripHeightRef = useRef(null); - const previousContentOffsetsRef = useRef<{ - promptFromTop: number | null; - promptHeight: number | null; - actionFromBottom: number | null; - controlsFromBottom: number | null; - controlsFromLeft: number | null; - surfaceBottomInset: number | null; - }>({ - promptFromTop: null, - promptHeight: null, - actionFromBottom: null, - controlsFromBottom: null, - controlsFromLeft: null, - surfaceBottomInset: null, - }); - const animationRef = useRef(null); - const animationTargetHeightRef = useRef(null); - const contentAnimationsRef = useRef([]); - const stateChangeAnimationsRef = useRef([]); - const pinnedOverlayRef = useRef(null); - const transitionCleanupTimeoutRef = useRef(null); - const transitionLayoutRequestRef = useRef(0); - const hasCompletedInitialLayoutRef = useRef(false); - - const clearOverlayPin = () => { - // 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 = () => { - const element = elementRef.current; - const footer = element?.querySelector('[data-chat-composer-footer="true"]'); - element?.style.removeProperty("overflow"); - element?.style.removeProperty("clip-path"); - element?.style.removeProperty("overflow-clip-margin"); - element - ?.querySelector('[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"); - const shell = element?.closest('[data-slot="composer-shell"]'); - shell?.removeAttribute("data-model-strip-transition"); - const modelStrip = shell?.querySelector('[data-composer-model-strip="true"]'); - for (const property of [ - "position", - "top", - "visibility", - "height", - "min-height", - "padding-top", - "align-items", - "z-index", - "pointer-events", - ]) { - modelStrip?.style.removeProperty(property); - } - clearOverlayPin(); - }; - - useLayoutEffect(() => { - isCollapsedRef.current = isCollapsed; - }); - - const transitionToCurrentGeometry = (stateChanged: boolean) => { - const element = elementRef.current; - const surface = element?.querySelector('[data-chat-composer-surface="true"]'); - if (!element || !surface) return; - - const nextIsCollapsed = isCollapsedRef.current; - - const visibleTransitionElement = (selector: string) => - Array.from(element.querySelectorAll(selector)).find( - (candidate) => candidate.getClientRects().length > 0, - ) ?? null; - const prompt = visibleTransitionElement( - '[data-composer-prompt-surface="true"], [data-chat-composer-transition-prompt="true"]', - ); - const action = visibleTransitionElement('[data-chat-composer-transition-actions="true"]'); - const footer = element.querySelector('[data-chat-composer-footer="true"]'); - const continuousControls = - element.dataset.inlineRestingControls === "true" || element.dataset.modelOnlyStrip === "true"; - const controls = nextIsCollapsed - ? restingControlsRef.current - : element.querySelector('[data-chat-composer-controls="left"]'); - - const shell = element.closest('[data-slot="composer-shell"]'); - const modelStrip = shell?.querySelector('[data-composer-model-strip="true"]'); - const interruptedStripHeight = - animationRef.current && modelStrip ? modelStrip.getBoundingClientRect().height : null; - const interruptedAnimation = animationRef.current; - const interruptedPromptTop = interruptedAnimation - ? (prompt?.getBoundingClientRect().top ?? null) - : null; - const interruptedActionTop = interruptedAnimation - ? (action?.getBoundingClientRect().top ?? null) - : null; - const interruptedControlsLeft = interruptedAnimation - ? (controls?.getBoundingClientRect().left ?? null) - : null; - const interruptedControlsTop = interruptedAnimation - ? (controls?.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 = []; - if (stateChanged) { - for (const animation of stateChangeAnimationsRef.current) animation.cancel(); - stateChangeAnimationsRef.current = []; - } - clearTransitionStyles(); - - const nextRect = element.getBoundingClientRect(); - const nextHeight = nextRect.height; - const nextModelStripHeight = modelStrip?.getBoundingClientRect().height ?? null; - // 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('[data-chat-composer-overlay="true"]'); - const overlayRect = overlay?.getBoundingClientRect(); - const overlayHeight = overlayRect?.height ?? null; - if (overlayHeight !== null) { - onOverlayHeightChange(overlayHeight); - } - const nextPromptRect = prompt?.getBoundingClientRect() ?? null; - const nextPromptTop = nextPromptRect?.top ?? null; - const nextActionTop = action?.getBoundingClientRect().top ?? null; - const nextControlsRect = controls?.getBoundingClientRect() ?? null; - const nextControlsTop = nextControlsRect?.top ?? null; - const footerBottom = footer ? nextRect.bottom - footer.getBoundingClientRect().bottom : 1; - const previousHeight = interruptedHeight ?? previousHeightRef.current; - const targetChanged = - interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5; - const shouldAnimate = shouldAnimateComposerRestingTransition({ - hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current, - stateChanged, - hasInterruptedAnimation: interruptedHeight !== null, - }); - - if ( - shouldAnimate && - animationsActive && - previousHeight !== null && - Math.abs(previousHeight - nextHeight) >= 0.5 - ) { - const remainingDuration = - typeof interruptedDuration === "number" && interruptedCurrentTime !== null - ? Math.max(1, interruptedDuration - interruptedCurrentTime) - : animationDurationMs; - const duration = - interruptedHeight !== null && !targetChanged ? remainingDuration : animationDurationMs; - element.style.overflow = "clip"; - if (modelStrip) { - const controlsClearance = Math.max( - nextModelStripHeight ?? 0, - previousModelStripHeightRef.current ?? 0, - ); - element.style.overflowClipMargin = `${controlsClearance}px`; - element.style.clipPath = `inset(0 0 -${controlsClearance}px 0)`; - } - surface.style.height = "100%"; - - 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; - } - - if (footer) { - footer.style.position = "absolute"; - footer.style.top = "auto"; - footer.style.bottom = `${String(footerBottom)}px`; - footer.style.height = "3rem"; - if (nextIsCollapsed) { - footer.style.left = "auto"; - footer.style.right = "1px"; - } else { - footer.style.left = "1px"; - footer.style.right = "1px"; - } - } - - let stripAnimation: Animation | null = null; - if (modelStrip) { - const stripHeight = modelStrip.getBoundingClientRect().height; - const stripOverlap = -Number.parseFloat(getComputedStyle(modelStrip).marginTop); - const fromHeight = - interruptedStripHeight ?? - (previousCollapsedRef.current - ? (previousModelStripHeightRef.current ?? stripHeight) - : stripOverlap); - const toHeight = nextIsCollapsed ? stripHeight : stripOverlap; - modelStrip.style.position = "relative"; - modelStrip.style.top = "auto"; - modelStrip.style.visibility = "visible"; - modelStrip.style.minHeight = "0"; - modelStrip.style.paddingTop = "0"; - modelStrip.style.alignItems = "flex-end"; - modelStrip.style.pointerEvents = "none"; - modelStrip.style.zIndex = "20"; - shell?.setAttribute("data-model-strip-transition", "true"); - stripAnimation = modelStrip.animate( - [{ height: `${fromHeight}px` }, { height: `${toHeight}px` }], - { duration, easing: COMPOSER_RESTING_TRANSITION_EASING, fill: "both" }, - ); - } - - const animation = element.animate( - [{ height: `${previousHeight}px` }, { height: `${nextHeight}px` }], - { - duration, - easing: COMPOSER_RESTING_TRANSITION_EASING, - fill: "both", - }, - ); - animationRef.current = animation; - animationTargetHeightRef.current = nextHeight; - - const animatedRect = element.getBoundingClientRect(); - const previousBottom = - overlayRect && previousContentOffsetsRef.current.surfaceBottomInset !== null - ? overlayRect.bottom - previousContentOffsetsRef.current.surfaceBottomInset - : animatedRect.bottom; - const bottomShift = previousBottom - animatedRect.bottom; - const previousPromptTop = - interruptedPromptTop ?? - (previousContentOffsetsRef.current.promptFromTop === null - ? null - : animatedRect.top + bottomShift + previousContentOffsetsRef.current.promptFromTop); - const previousActionTop = - interruptedActionTop ?? - (previousContentOffsetsRef.current.actionFromBottom === null - ? null - : previousBottom - previousContentOffsetsRef.current.actionFromBottom); - const contentAnimations: Animation[] = stripAnimation ? [stripAnimation] : []; - const animateContentPosition = ( - content: HTMLElement | null, - previousTop: number | null, - previousLeft: number | null = null, - ) => { - if (!content || previousTop === null) return; - const rect = content.getBoundingClientRect(); - const offset = previousTop - rect.top; - const offsetX = previousLeft === null ? 0 : previousLeft - rect.left; - if (Math.abs(offset) < 0.5 && Math.abs(offsetX) < 0.5) return; - contentAnimations.push( - content.animate( - [{ transform: `translate(${offsetX}px, ${offset}px)` }, { transform: "none" }], - { - duration, - easing: COMPOSER_RESTING_TRANSITION_EASING, - }, - ), - ); - }; - animateContentPosition(prompt, previousPromptTop); - animateContentPosition(action, previousActionTop); - if (continuousControls) { - const previousControlsTop = - interruptedControlsTop ?? - (previousContentOffsetsRef.current.controlsFromBottom === null - ? null - : previousBottom - previousContentOffsetsRef.current.controlsFromBottom); - const previousControlsLeft = - interruptedControlsLeft ?? - (previousContentOffsetsRef.current.controlsFromLeft === null - ? null - : animatedRect.left + previousContentOffsetsRef.current.controlsFromLeft); - animateContentPosition(controls, previousControlsTop, previousControlsLeft); - } - contentAnimationsRef.current = contentAnimations; - - if (stateChanged) { - const stateChangeAnimations: Animation[] = []; - - const previousPromptHeight = previousContentOffsetsRef.current.promptHeight; - if ( - !nextIsCollapsed && - prompt && - nextPromptRect && - previousPromptHeight !== null && - nextPromptRect.height - previousPromptHeight >= 0.5 - ) { - const hiddenHeight = nextPromptRect.height - previousPromptHeight; - stateChangeAnimations.push( - prompt.animate( - [ - { clipPath: `inset(0 0 ${String(hiddenHeight)}px 0)` }, - { clipPath: "inset(0 0 0 0)" }, - ], - { - duration, - easing: COMPOSER_RESTING_TRANSITION_EASING, - }, - ), - ); - } - - const arrivingControls = nextIsCollapsed - ? restingControlsRef.current - : element.querySelector('[data-chat-composer-controls="left"]'); - if (arrivingControls && !continuousControls) { - const drift = nextIsCollapsed - ? -COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX - : COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX; - stateChangeAnimations.push( - arrivingControls.animate( - [ - { opacity: 0, transform: `translateY(${String(drift)}px)` }, - { opacity: 1, transform: "none" }, - ], - { - duration: nextIsCollapsed ? duration : duration / 2, - delay: nextIsCollapsed ? 0 : duration / 2, - fill: "backwards", - easing: COMPOSER_RESTING_TRANSITION_EASING, - }, - ), - ); - } - - const arrivingImagePreviews = nextIsCollapsed - ? Array.from( - element.querySelectorAll('[data-chat-composer-resting-images="true"]'), - ) - : Array.from( - element.querySelectorAll('[data-chat-composer-expanded-image="true"]'), - ); - for (const imagePreview of arrivingImagePreviews) { - stateChangeAnimations.push( - imagePreview.animate([{ opacity: 0 }, { opacity: 1 }], { - duration: nextIsCollapsed ? duration : duration / 2, - delay: nextIsCollapsed ? 0 : duration / 2, - fill: "backwards", - easing: COMPOSER_RESTING_TRANSITION_EASING, - }), - ); - } - stateChangeAnimationsRef.current = stateChangeAnimations; - } - - const finishTransition = (cancelAnimations: boolean) => { - if (animationRef.current !== animation) return; - if (transitionCleanupTimeoutRef.current !== null) { - window.clearTimeout(transitionCleanupTimeoutRef.current); - transitionCleanupTimeoutRef.current = null; - } - if (cancelAnimations) { - animation.cancel(); - for (const contentAnimation of contentAnimationsRef.current) { - contentAnimation.cancel(); - } - for (const stateChangeAnimation of stateChangeAnimationsRef.current) { - stateChangeAnimation.cancel(); - } - } - animationRef.current = null; - animationTargetHeightRef.current = null; - contentAnimationsRef.current = []; - stateChangeAnimationsRef.current = []; - animation.cancel(); - stripAnimation?.cancel(); - clearTransitionStyles(); - }; - void animation.finished.catch(() => undefined).then(() => finishTransition(false)); - // A suspended document timeline can leave `finished` pending while - // these measurement styles remain active. Wall-clock cleanup makes - // the natural layout the eventual source of truth in that case. - transitionCleanupTimeoutRef.current = window.setTimeout( - () => finishTransition(true), - duration + COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS, - ); - } else { - animationTargetHeightRef.current = null; - } - - previousCollapsedRef.current = nextIsCollapsed; - previousHeightRef.current = nextHeight; - previousModelStripHeightRef.current = nextModelStripHeight; - previousContentOffsetsRef.current = { - promptFromTop: nextPromptTop === null ? null : nextPromptTop - nextRect.top, - promptHeight: nextPromptRect?.height ?? null, - actionFromBottom: nextActionTop === null ? null : nextRect.bottom - nextActionTop, - controlsFromBottom: nextControlsTop === null ? null : nextRect.bottom - nextControlsTop, - controlsFromLeft: nextControlsRect === null ? null : nextControlsRect.left - nextRect.left, - surfaceBottomInset: overlayRect ? overlayRect.bottom - nextRect.bottom : null, - }; - }; - - useLayoutEffect(() => { - const requestId = transitionLayoutRequestRef.current + 1; - transitionLayoutRequestRef.current = requestId; - const stateChanged = previousCollapsedRef.current !== isCollapsed; - // A non-Git context strip enters or leaves flow through ChatView state in - // an earlier layout effect. Let React flush that parent update before the - // FLIP reads its destination geometry, while still running before paint. - queueMicrotask(() => { - if (transitionLayoutRequestRef.current !== requestId) return; - transitionToCurrentGeometry(stateChanged); - }); - return () => { - if (transitionLayoutRequestRef.current === requestId) { - transitionLayoutRequestRef.current += 1; - } - }; - }, [isCollapsed, transitionToCurrentGeometry]); - - useLayoutEffect(() => { - if (previousRestingRef.current === isResting) return; - previousRestingRef.current = isResting; - if (animationRef.current) return; - const overlay = elementRef.current?.closest('[data-chat-composer-overlay="true"]'); - if (overlay) onOverlayHeightChange(overlay.getBoundingClientRect().height); - }, [isResting, onOverlayHeightChange]); - - useLayoutEffect(() => { - const element = elementRef.current; - if (!element) return; - - const body = element.querySelector('[data-chat-composer-body="true"]'); - return observeResize(body ? [element, body] : element, (entries) => { - if (animationRef.current) { - if (body && entries.some((entry) => entry.target === body)) { - transitionToCurrentGeometry(false); - } - return; - } - const elementRect = element.getBoundingClientRect(); - const visibleTransitionElement = (selector: string) => - Array.from(element.querySelectorAll(selector)).find( - (candidate) => candidate.getClientRects().length > 0, - ) ?? null; - const promptRect = visibleTransitionElement( - '[data-composer-prompt-surface="true"], [data-chat-composer-transition-prompt="true"]', - )?.getBoundingClientRect(); - const actionTop = visibleTransitionElement( - '[data-chat-composer-transition-actions="true"]', - )?.getBoundingClientRect().top; - const controlsRect = ( - isCollapsedRef.current - ? restingControlsRef.current - : visibleTransitionElement('[data-chat-composer-controls="left"]') - )?.getBoundingClientRect(); - previousHeightRef.current = elementRect.height; - previousContentOffsetsRef.current = { - promptFromTop: promptRect === undefined ? null : promptRect.top - elementRect.top, - promptHeight: promptRect?.height ?? null, - actionFromBottom: actionTop === undefined ? null : elementRect.bottom - actionTop, - controlsFromBottom: - controlsRect === undefined ? null : elementRect.bottom - controlsRect.top, - controlsFromLeft: controlsRect === undefined ? null : controlsRect.left - elementRect.left, - surfaceBottomInset: - (element - .closest('[data-chat-composer-overlay="true"]') - ?.getBoundingClientRect().bottom ?? elementRect.bottom) - elementRect.bottom, - }; - }); - }, [restingControlsRef, transitionToCurrentGeometry]); - - useEffect(() => { - hasCompletedInitialLayoutRef.current = true; - return () => { - if (transitionCleanupTimeoutRef.current !== null) { - window.clearTimeout(transitionCleanupTimeoutRef.current); - transitionCleanupTimeoutRef.current = null; - } - animationRef.current?.cancel(); - animationRef.current = null; - animationTargetHeightRef.current = null; - for (const animation of contentAnimationsRef.current) animation.cancel(); - contentAnimationsRef.current = []; - for (const animation of stateChangeAnimationsRef.current) animation.cancel(); - stateChangeAnimationsRef.current = []; - clearTransitionStyles(); - }; - }, [clearTransitionStyles]); - - return elementRef; -} - -function composerCommandMenuPositionsEqual( - a: ComposerCommandMenuPosition, - b: ComposerCommandMenuPosition, -): boolean { - return ( - a.bottom === b.bottom && a.left === b.left && a.maxHeight === b.maxHeight && a.width === b.width - ); -} - -function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ReactNode }) { - const [position, setPosition] = useState(null); - - useLayoutEffect(() => { - const anchor = props.anchor; - if (!anchor) return; - - const updatePosition = () => { - const form = anchor.closest('[data-chat-composer-form="true"]'); - const mainSurface = form?.querySelector( - '[data-chat-composer-main-surface="true"]', - ); - const rect = (mainSurface ?? form ?? anchor).getBoundingClientRect(); - const rootFontSizePx = - Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16; - const drawerInsetRem = Number.parseFloat( - window.getComputedStyle(form ?? anchor).getPropertyValue("--chat-composer-drawer-inset"), - ); - const drawerInset = drawerInsetRem * rootFontSizePx; - const width = rect.width - drawerInset * 2; - if (width <= 0) { - setPosition(null); - return; - } - const composerOverlap = rootFontSizePx + 1; - const next = { - bottom: window.innerHeight - rect.top - composerOverlap, - left: rect.left + drawerInset, - maxHeight: Math.max(96, rect.top - 24 + composerOverlap), - width, - }; - setPosition((current) => - current && composerCommandMenuPositionsEqual(current, next) ? current : next, - ); - }; - - updatePosition(); - window.addEventListener("resize", updatePosition); - window.addEventListener("scroll", updatePosition, true); - - const observed: Element[] = [anchor]; - for (let element = anchor.parentElement; element; element = element.parentElement) { - observed.push(element); - } - const stopObserving = observeResize(observed, updatePosition); - - return () => { - stopObserving(); - window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition, true); - }; - }, [props.anchor]); - - if (!props.anchor || !position) return null; - - return createPortal( -
- {props.children} -
, - document.body, - ); -} import { toastManager } from "../ui/toast"; import { CircleAlertIcon, PlayIcon, ShieldIcon } from "lucide-react"; import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; import { hasProviderSetup } from "./ProviderStatusBanner"; -import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, - NO_PROVIDER_MODEL_SELECTION, - sortProviderInstanceEntries, - type ProviderInstanceEntry, -} from "../../providerInstances"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; import { @@ -981,8 +350,6 @@ import { } from "@t3tools/client-runtime/state/composer-dispatch"; import type { ContextWindowSnapshot } from "../../lib/contextWindow"; import { - hasCompleteProviderWorkspaceSnapshot, - hasCurrentProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; @@ -993,10 +360,13 @@ import { useStableCallback } from "../../hooks/useStableCallback"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; import { readEnvironmentScope } from "../../state/session"; -import { serverEnvironment } from "../../state/server"; import type { ReviewCommentContext } from "../../reviewCommentContext"; - -const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; +import { useComposerRestingTransition } from "./useComposerRestingTransition"; +import { ComposerCommandMenuLayer } from "./ComposerCommandMenuLayer"; +import { useRestingComposerControlsLayout } from "./useRestingComposerControlsLayout"; +import { useProviderWorkspaceSnapshotRefresh } from "./useProviderWorkspaceSnapshotRefresh"; +import { useComposerProviderModel } from "./useComposerProviderModel"; +import { useComposerImageGroups } from "./useComposerImageGroups"; /** Reads and disarms a paste-as-text deadline: the shortcut applies to one paste only. */ function consumePasteAsTextShortcut(deadline: { current: number }): boolean { @@ -1025,83 +395,6 @@ const extendReplacementRangeForTrailingSpace = ( return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; }; -function useRestingComposerControlsLayout(host: HTMLDivElement | null, useControlsAsHost = false) { - const [controls, setControls] = useState(null); - const controlsRef = useRef(null); - const attachControls = (element: HTMLDivElement | null) => { - controlsRef.current = element; - setControls(element); - }; - const [layout, setLayout] = useState<{ - hiddenCount: number; - iconOnlyCount?: number; - visible: boolean; - }>({ hiddenCount: 0, visible: true }); - - const measure = () => { - const currentHost = useControlsAsHost ? controls : host; - if (currentHost === null || !controls) return; - - const measurement = measureRestingComposerControls(controls); - if (!measurement) return; - const style = getComputedStyle(currentHost); - const hostWidth = - currentHost.clientWidth - - (Number.parseFloat(style.paddingInlineStart) || 0) - - (Number.parseFloat(style.paddingInlineEnd) || 0); - - setLayout((current) => { - const next = resolveRestingComposerControlsLayout({ - ...measurement, - hostWidth, - previous: current, - }); - return next.hiddenCount === current.hiddenCount && - next.iconOnlyCount === current.iconOnlyCount && - next.visible === current.visible - ? current - : next; - }); - }; - - useLayoutEffect(() => { - const currentHost = useControlsAsHost ? controls : host; - if (!currentHost || !controls) return; - let stopObserving = () => {}; - const observeControls = () => { - stopObserving(); - stopObserving = observeResize( - [ - currentHost, - controls, - ...controls.querySelectorAll( - "[data-resting-block], [data-composer-control-label], [data-chat-provider-model-picker-label]", - ), - ], - measure, - ); - measure(); - }; - observeControls(); - const mutations = new MutationObserver(observeControls); - mutations.observe(controls, { childList: true, subtree: true, characterData: true }); - document.fonts.addEventListener("loadingdone", measure); - return () => { - stopObserving(); - mutations.disconnect(); - document.fonts.removeEventListener("loadingdone", measure); - }; - }, [host, controls, useControlsAsHost, measure]); - - return { - controlsRef, - attachControls, - hiddenBlockCount: layout.hiddenCount, - iconOnlyBlockCount: layout.iconOnlyCount ?? 0, - controlsVisible: layout.visible, - }; -} - const runtimeModeOptions = runtimeModes.map((mode) => ({ mode, ...runtimeModeConfig[mode] })); export interface ChatComposerHandle { @@ -1330,237 +623,6 @@ export interface ChatComposerProps { onFileOpen: (attachment: ChatFileAttachment) => void; } -/** - * Keeps the selected provider's workspace snapshot fresh for the composer's - * cwd. Returns a check to run when the user types. - */ -function useProviderWorkspaceSnapshotRefresh(input: { - readonly environmentId: EnvironmentId; - readonly gitCwd: string | null; - readonly selectedProviderEntry: ProviderInstanceEntry | undefined; - readonly selectedProviderStatus: ServerProvider | null; -}) { - const { environmentId, gitCwd, selectedProviderEntry, selectedProviderStatus } = input; - const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); - // The last scan this composer asked for. A request inside the TTL is not - // repeated, so a client clock ahead of the server's cannot loop rescans. - const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); - const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ - key: string; - notBefore: number; - } | null>(null); - const workspaceRefreshScopeKey = - gitCwd && selectedProviderEntry - ? `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}` - : null; - const workspaceSlashCommandsPending = - selectedProviderStatus?.workspaceSnapshots?.some( - (snapshot) => snapshot.cwd === gitCwd && snapshot.slashCommandsPending === true, - ) ?? false; - useEffect(() => { - if ( - !workspaceSlashCommandsPending || - !workspaceRefreshRetry || - workspaceRefreshRetry.key !== workspaceRefreshScopeKey - ) - return; - const timeout = setTimeout( - () => { - setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); - }, - Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), - ); - return () => clearTimeout(timeout); - }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); - // Losing a snapshot (the server dropped it) lifts both the TTL and the retry - // cooldown so the next check rescans at once. - const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd); - const [hadWorkspaceSnapshot, setHadWorkspaceSnapshot] = useState(hasWorkspaceSnapshot); - const [workspaceSnapshotLossCount, setWorkspaceSnapshotLossCount] = useState(0); - if (hadWorkspaceSnapshot !== hasWorkspaceSnapshot) { - setHadWorkspaceSnapshot(hasWorkspaceSnapshot); - if (!hasWorkspaceSnapshot) { - setWorkspaceSnapshotLossCount((count) => count + 1); - setWorkspaceRefreshRetry(null); - } - } - const lastWorkspaceSnapshotLossRef = useRef(workspaceSnapshotLossCount); - const refreshWorkspaceSnapshotIfStale = ( - now: number, - key: string | null, - retry: { key: string; notBefore: number } | null, - snapshotLossCount: number, - ) => { - if (lastWorkspaceSnapshotLossRef.current !== snapshotLossCount) { - lastWorkspaceSnapshotLossRef.current = snapshotLossCount; - workspaceRefreshKeyRef.current = null; - } - if (key === null || !gitCwd || !selectedProviderEntry) return; - const lastRequest = workspaceRefreshKeyRef.current; - if ( - lastRequest?.key === key && - now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - ) - return; - // A pending retry needs no clearing here: its cooldown lapses long before the TTL. - if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd, now)) return; - if (retry?.key === key && now < retry.notBefore) return; - const request = { key, requestedAt: now }; - workspaceRefreshKeyRef.current = request; - const retryLater = () => { - if (workspaceRefreshKeyRef.current !== request) return; - workspaceRefreshKeyRef.current = null; - setWorkspaceRefreshRetry({ - key, - notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, - }); - }; - void refreshProviders({ - environmentId, - input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd }, - }).then((result) => { - const refreshedSnapshot = - result._tag === "Success" && - hasCompleteProviderWorkspaceSnapshot( - result.value.providers.find( - (provider) => provider.instanceId === selectedProviderEntry.instanceId, - ), - gitCwd, - ); - if (!refreshedSnapshot) retryLater(); - }, retryLater); - }; - const refreshWorkspaceSnapshotOnChange = useEffectEvent(refreshWorkspaceSnapshotIfStale); - useEffect(() => { - refreshWorkspaceSnapshotOnChange( - Date.now(), - workspaceRefreshScopeKey, - workspaceRefreshRetry, - workspaceSnapshotLossCount, - ); - }, [workspaceRefreshScopeKey, workspaceRefreshRetry, workspaceSnapshotLossCount]); - return useStableCallback(() => - refreshWorkspaceSnapshotIfStale( - Date.now(), - workspaceRefreshScopeKey, - workspaceRefreshRetry, - workspaceSnapshotLossCount, - ), - ); -} - -const NO_PROVIDER_MODELS: ServerProvider["models"] = []; - -/** - * The provider instances the composer offers, which one is selected, and the model and options - * the draft resolves to on it. The draft store is read first so every derivation follows the only - * hook here; in the composer body they would straddle its later hooks and lose their cache. - */ -function useComposerProviderModel( - input: Omit[0], "entries"> & { - readonly providerStatuses: ReadonlyArray; - readonly settings: UnifiedSettings; - readonly activeProjectId: ProjectId | null; - readonly threadRef: ScopedThreadRef | DraftId; - readonly threadModelSelection: ModelSelection | null | undefined; - readonly projectModelSelection: ModelSelection | null | undefined; - readonly promptInjectionState: ReturnType; - }, -) { - const { - providerStatuses, - settings, - activeProjectId, - threadRef, - threadModelSelection, - projectModelSelection, - promptInjectionState, - candidateInstanceIds, - lockedProvider, - lockedInstanceId, - } = input; - const draftModelState = useComposerDraftModelState(threadRef); - const providerInstanceEntries: ReadonlyArray = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(providerStatuses), - settings, - activeProjectId, - ), - ); - const selection = resolveComposerProviderSelection({ - entries: providerInstanceEntries, - candidateInstanceIds, - lockedProvider, - lockedInstanceId, - }); - const selectedInstanceId = - selection.selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; - const selectedProvider: ProviderDriverKind = - selection.selectedProviderEntry?.driverKind ?? selection.requestedDriverKind; - const modelState = deriveEffectiveComposerModelState({ - draft: draftModelState, - providers: providerStatuses, - selectedProvider, - selectedInstanceId, - threadModelSelection, - projectModelSelection, - settings, - }); - const selectedProviderModels = selection.selectedProviderEntry?.models ?? NO_PROVIDER_MODELS; - const composerProviderState = getComposerProviderState({ - provider: selectedProvider, - model: modelState.selectedModel, - models: selectedProviderModels, - promptInjectionState, - modelOptions: modelState.modelOptions?.[selectedInstanceId], - planModeEnabled: settings.planModeEnabled, - }); - return { - providerInstanceEntries, - ...selection, - selectedInstanceId, - selectedProvider, - selectedProviderModels, - composerModelOptions: modelState.modelOptions, - selectedModel: modelState.selectedModel, - composerProviderState, - }; -} - -/** - * Follows the SnapShots still animating into this draft and splits the draft's images into - * pending SnapShots and standalone images. - */ -function useComposerImageGroups( - composerDraftTarget: ScopedThreadRef | DraftId, - composerImages: ReadonlyArray, - composerPreviewAnnotations: ReadonlyArray<{ readonly id: string }>, -) { - const pendingSnapShotAnimations = useSyncExternalStore( - subscribeToPendingSnapShotAnimations, - getPendingSnapShotAnimations, - getPendingSnapShotAnimations, - ); - const pendingSnapShotIds = pendingSnapShotAnimationIdsForTarget( - pendingSnapShotAnimations, - composerDraftTarget, - ); - const previewAnnotationIds = new Set( - composerPreviewAnnotations.map((annotation) => annotation.id), - ); - return { - pendingSnapShotAnimations, - pendingSnapShotIds, - pendingSnapShotIdSet: new Set(pendingSnapShotIds), - uncommittedSnapShotIds: pendingSnapShotIds.filter( - (id) => !composerImages.some((image) => image.id === id), - ), - standaloneComposerImages: composerImages.filter((image) => !previewAnnotationIds.has(image.id)), - }; -} - const NO_PROVIDER_SKILLS: ServerProvider["skills"] = []; const NO_PROVIDER_SLASH_COMMANDS: ServerProvider["slashCommands"] = []; diff --git a/apps/web/src/components/chat/ComposerCommandMenuLayer.tsx b/apps/web/src/components/chat/ComposerCommandMenuLayer.tsx new file mode 100644 index 000000000000..a449fc6a442a --- /dev/null +++ b/apps/web/src/components/chat/ComposerCommandMenuLayer.tsx @@ -0,0 +1,94 @@ +import { type ReactNode, useLayoutEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { observeResize } from "~/lib/observeResize"; + +type ComposerCommandMenuPosition = { + bottom: number; + left: number; + maxHeight: number; + width: number; +}; + +function composerCommandMenuPositionsEqual( + a: ComposerCommandMenuPosition, + b: ComposerCommandMenuPosition, +): boolean { + return ( + a.bottom === b.bottom && a.left === b.left && a.maxHeight === b.maxHeight && a.width === b.width + ); +} + +export function ComposerCommandMenuLayer(props: { + anchor: HTMLElement | null; + children: ReactNode; +}) { + const [position, setPosition] = useState(null); + + useLayoutEffect(() => { + const anchor = props.anchor; + if (!anchor) return; + + const updatePosition = () => { + const form = anchor.closest('[data-chat-composer-form="true"]'); + const mainSurface = form?.querySelector( + '[data-chat-composer-main-surface="true"]', + ); + const rect = (mainSurface ?? form ?? anchor).getBoundingClientRect(); + const rootFontSizePx = + Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16; + const drawerInsetRem = Number.parseFloat( + window.getComputedStyle(form ?? anchor).getPropertyValue("--chat-composer-drawer-inset"), + ); + const drawerInset = drawerInsetRem * rootFontSizePx; + const width = rect.width - drawerInset * 2; + if (width <= 0) { + setPosition(null); + return; + } + const composerOverlap = rootFontSizePx + 1; + const next = { + bottom: window.innerHeight - rect.top - composerOverlap, + left: rect.left + drawerInset, + maxHeight: Math.max(96, rect.top - 24 + composerOverlap), + width, + }; + setPosition((current) => + current && composerCommandMenuPositionsEqual(current, next) ? current : next, + ); + }; + + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + + const observed: Element[] = [anchor]; + for (let element = anchor.parentElement; element; element = element.parentElement) { + observed.push(element); + } + const stopObserving = observeResize(observed, updatePosition); + + return () => { + stopObserving(); + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [props.anchor]); + + if (!props.anchor || !position) return null; + + return createPortal( +
+ {props.children} +
, + document.body, + ); +} diff --git a/apps/web/src/components/chat/useComposerImageGroups.ts b/apps/web/src/components/chat/useComposerImageGroups.ts new file mode 100644 index 000000000000..87342ae87f4f --- /dev/null +++ b/apps/web/src/components/chat/useComposerImageGroups.ts @@ -0,0 +1,40 @@ +import { type ComposerImageAttachment, type DraftId } from "../../composerDraftStore"; +import { type ScopedThreadRef } from "@t3tools/contracts"; +import { + getPendingSnapShotAnimations, + pendingSnapShotAnimationIdsForTarget, + subscribeToPendingSnapShotAnimations, +} from "../../lib/snapShotAnimation"; +import { useSyncExternalStore } from "react"; + +/** + * Follows the SnapShots still animating into this draft and splits the draft's images into + * pending SnapShots and standalone images. + */ +export function useComposerImageGroups( + composerDraftTarget: ScopedThreadRef | DraftId, + composerImages: ReadonlyArray, + composerPreviewAnnotations: ReadonlyArray<{ readonly id: string }>, +) { + const pendingSnapShotAnimations = useSyncExternalStore( + subscribeToPendingSnapShotAnimations, + getPendingSnapShotAnimations, + getPendingSnapShotAnimations, + ); + const pendingSnapShotIds = pendingSnapShotAnimationIdsForTarget( + pendingSnapShotAnimations, + composerDraftTarget, + ); + const previewAnnotationIds = new Set( + composerPreviewAnnotations.map((annotation) => annotation.id), + ); + return { + pendingSnapShotAnimations, + pendingSnapShotIds, + pendingSnapShotIdSet: new Set(pendingSnapShotIds), + uncommittedSnapShotIds: pendingSnapShotIds.filter( + (id) => !composerImages.some((image) => image.id === id), + ), + standaloneComposerImages: composerImages.filter((image) => !previewAnnotationIds.has(image.id)), + }; +} diff --git a/apps/web/src/components/chat/useComposerProviderModel.ts b/apps/web/src/components/chat/useComposerProviderModel.ts new file mode 100644 index 000000000000..24920d1593b4 --- /dev/null +++ b/apps/web/src/components/chat/useComposerProviderModel.ts @@ -0,0 +1,100 @@ +import { + type DraftId, + deriveEffectiveComposerModelState, + useComposerDraftModelState, +} from "../../composerDraftStore"; +import { + type ModelSelection, + type ProjectId, + ProviderDriverKind, + type ScopedThreadRef, + type ServerProvider, +} from "@t3tools/contracts"; +import { + NO_PROVIDER_MODEL_SELECTION, + type ProviderInstanceEntry, + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { type UnifiedSettings } from "@t3tools/contracts/settings"; +import { getComposerPromptInjectionState, getComposerProviderState } from "./composerProviderState"; +import { resolveComposerProviderSelection } from "../ChatView.logic"; + +const NO_PROVIDER_MODELS: ServerProvider["models"] = []; + +/** + * The provider instances the composer offers, which one is selected, and the model and options + * the draft resolves to on it. The draft store is read first so every derivation follows the only + * hook here; in the composer body they would straddle its later hooks and lose their cache. + */ +export function useComposerProviderModel( + input: Omit[0], "entries"> & { + readonly providerStatuses: ReadonlyArray; + readonly settings: UnifiedSettings; + readonly activeProjectId: ProjectId | null; + readonly threadRef: ScopedThreadRef | DraftId; + readonly threadModelSelection: ModelSelection | null | undefined; + readonly projectModelSelection: ModelSelection | null | undefined; + readonly promptInjectionState: ReturnType; + }, +) { + const { + providerStatuses, + settings, + activeProjectId, + threadRef, + threadModelSelection, + projectModelSelection, + promptInjectionState, + candidateInstanceIds, + lockedProvider, + lockedInstanceId, + } = input; + const draftModelState = useComposerDraftModelState(threadRef); + const providerInstanceEntries: ReadonlyArray = sortProviderInstanceEntries( + applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + activeProjectId, + ), + ); + const selection = resolveComposerProviderSelection({ + entries: providerInstanceEntries, + candidateInstanceIds, + lockedProvider, + lockedInstanceId, + }); + const selectedInstanceId = + selection.selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; + const selectedProvider: ProviderDriverKind = + selection.selectedProviderEntry?.driverKind ?? selection.requestedDriverKind; + const modelState = deriveEffectiveComposerModelState({ + draft: draftModelState, + providers: providerStatuses, + selectedProvider, + selectedInstanceId, + threadModelSelection, + projectModelSelection, + settings, + }); + const selectedProviderModels = selection.selectedProviderEntry?.models ?? NO_PROVIDER_MODELS; + const composerProviderState = getComposerProviderState({ + provider: selectedProvider, + model: modelState.selectedModel, + models: selectedProviderModels, + promptInjectionState, + modelOptions: modelState.modelOptions?.[selectedInstanceId], + planModeEnabled: settings.planModeEnabled, + }); + return { + providerInstanceEntries, + ...selection, + selectedInstanceId, + selectedProvider, + selectedProviderModels, + composerModelOptions: modelState.modelOptions, + selectedModel: modelState.selectedModel, + composerProviderState, + }; +} diff --git a/apps/web/src/components/chat/useComposerRestingTransition.ts b/apps/web/src/components/chat/useComposerRestingTransition.ts new file mode 100644 index 000000000000..64b18b677801 --- /dev/null +++ b/apps/web/src/components/chat/useComposerRestingTransition.ts @@ -0,0 +1,526 @@ +import { observeResize } from "~/lib/observeResize"; +import { shouldAnimateComposerRestingTransition } from "../composerFooterLayout"; +import { useEffect, useLayoutEffect, useRef } from "react"; + +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; + +export function useComposerRestingTransition( + isCollapsed: boolean, + isResting: boolean, + restingControlsRef: React.RefObject, + onOverlayHeightChange: (height: number) => void, + animationsActive: boolean, + animationDurationMs: number, +) { + const elementRef = useRef(null); + const isCollapsedRef = useRef(isCollapsed); + const previousCollapsedRef = useRef(isCollapsed); + const previousRestingRef = useRef(isResting); + const previousHeightRef = useRef(null); + const previousModelStripHeightRef = useRef(null); + const previousContentOffsetsRef = useRef<{ + promptFromTop: number | null; + promptHeight: number | null; + actionFromBottom: number | null; + controlsFromBottom: number | null; + controlsFromLeft: number | null; + surfaceBottomInset: number | null; + }>({ + promptFromTop: null, + promptHeight: null, + actionFromBottom: null, + controlsFromBottom: null, + controlsFromLeft: null, + surfaceBottomInset: null, + }); + const animationRef = useRef(null); + const animationTargetHeightRef = useRef(null); + const contentAnimationsRef = useRef([]); + const stateChangeAnimationsRef = useRef([]); + const pinnedOverlayRef = useRef(null); + const transitionCleanupTimeoutRef = useRef(null); + const transitionLayoutRequestRef = useRef(0); + const hasCompletedInitialLayoutRef = useRef(false); + + const clearOverlayPin = () => { + // 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 = () => { + const element = elementRef.current; + const footer = element?.querySelector('[data-chat-composer-footer="true"]'); + element?.style.removeProperty("overflow"); + element?.style.removeProperty("clip-path"); + element?.style.removeProperty("overflow-clip-margin"); + element + ?.querySelector('[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"); + const shell = element?.closest('[data-slot="composer-shell"]'); + shell?.removeAttribute("data-model-strip-transition"); + const modelStrip = shell?.querySelector('[data-composer-model-strip="true"]'); + for (const property of [ + "position", + "top", + "visibility", + "height", + "min-height", + "padding-top", + "align-items", + "z-index", + "pointer-events", + ]) { + modelStrip?.style.removeProperty(property); + } + clearOverlayPin(); + }; + + useLayoutEffect(() => { + isCollapsedRef.current = isCollapsed; + }); + + const transitionToCurrentGeometry = (stateChanged: boolean) => { + const element = elementRef.current; + const surface = element?.querySelector('[data-chat-composer-surface="true"]'); + if (!element || !surface) return; + + const nextIsCollapsed = isCollapsedRef.current; + + const visibleTransitionElement = (selector: string) => + Array.from(element.querySelectorAll(selector)).find( + (candidate) => candidate.getClientRects().length > 0, + ) ?? null; + const prompt = visibleTransitionElement( + '[data-composer-prompt-surface="true"], [data-chat-composer-transition-prompt="true"]', + ); + const action = visibleTransitionElement('[data-chat-composer-transition-actions="true"]'); + const footer = element.querySelector('[data-chat-composer-footer="true"]'); + const continuousControls = + element.dataset.inlineRestingControls === "true" || element.dataset.modelOnlyStrip === "true"; + const controls = nextIsCollapsed + ? restingControlsRef.current + : element.querySelector('[data-chat-composer-controls="left"]'); + + const shell = element.closest('[data-slot="composer-shell"]'); + const modelStrip = shell?.querySelector('[data-composer-model-strip="true"]'); + const interruptedStripHeight = + animationRef.current && modelStrip ? modelStrip.getBoundingClientRect().height : null; + const interruptedAnimation = animationRef.current; + const interruptedPromptTop = interruptedAnimation + ? (prompt?.getBoundingClientRect().top ?? null) + : null; + const interruptedActionTop = interruptedAnimation + ? (action?.getBoundingClientRect().top ?? null) + : null; + const interruptedControlsLeft = interruptedAnimation + ? (controls?.getBoundingClientRect().left ?? null) + : null; + const interruptedControlsTop = interruptedAnimation + ? (controls?.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 = []; + if (stateChanged) { + for (const animation of stateChangeAnimationsRef.current) animation.cancel(); + stateChangeAnimationsRef.current = []; + } + clearTransitionStyles(); + + const nextRect = element.getBoundingClientRect(); + const nextHeight = nextRect.height; + const nextModelStripHeight = modelStrip?.getBoundingClientRect().height ?? null; + // 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('[data-chat-composer-overlay="true"]'); + const overlayRect = overlay?.getBoundingClientRect(); + const overlayHeight = overlayRect?.height ?? null; + if (overlayHeight !== null) { + onOverlayHeightChange(overlayHeight); + } + const nextPromptRect = prompt?.getBoundingClientRect() ?? null; + const nextPromptTop = nextPromptRect?.top ?? null; + const nextActionTop = action?.getBoundingClientRect().top ?? null; + const nextControlsRect = controls?.getBoundingClientRect() ?? null; + const nextControlsTop = nextControlsRect?.top ?? null; + const footerBottom = footer ? nextRect.bottom - footer.getBoundingClientRect().bottom : 1; + const previousHeight = interruptedHeight ?? previousHeightRef.current; + const targetChanged = + interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5; + const shouldAnimate = shouldAnimateComposerRestingTransition({ + hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current, + stateChanged, + hasInterruptedAnimation: interruptedHeight !== null, + }); + + if ( + shouldAnimate && + animationsActive && + previousHeight !== null && + Math.abs(previousHeight - nextHeight) >= 0.5 + ) { + const remainingDuration = + typeof interruptedDuration === "number" && interruptedCurrentTime !== null + ? Math.max(1, interruptedDuration - interruptedCurrentTime) + : animationDurationMs; + const duration = + interruptedHeight !== null && !targetChanged ? remainingDuration : animationDurationMs; + element.style.overflow = "clip"; + if (modelStrip) { + const controlsClearance = Math.max( + nextModelStripHeight ?? 0, + previousModelStripHeightRef.current ?? 0, + ); + element.style.overflowClipMargin = `${controlsClearance}px`; + element.style.clipPath = `inset(0 0 -${controlsClearance}px 0)`; + } + surface.style.height = "100%"; + + 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; + } + + if (footer) { + footer.style.position = "absolute"; + footer.style.top = "auto"; + footer.style.bottom = `${String(footerBottom)}px`; + footer.style.height = "3rem"; + if (nextIsCollapsed) { + footer.style.left = "auto"; + footer.style.right = "1px"; + } else { + footer.style.left = "1px"; + footer.style.right = "1px"; + } + } + + let stripAnimation: Animation | null = null; + if (modelStrip) { + const stripHeight = modelStrip.getBoundingClientRect().height; + const stripOverlap = -Number.parseFloat(getComputedStyle(modelStrip).marginTop); + const fromHeight = + interruptedStripHeight ?? + (previousCollapsedRef.current + ? (previousModelStripHeightRef.current ?? stripHeight) + : stripOverlap); + const toHeight = nextIsCollapsed ? stripHeight : stripOverlap; + modelStrip.style.position = "relative"; + modelStrip.style.top = "auto"; + modelStrip.style.visibility = "visible"; + modelStrip.style.minHeight = "0"; + modelStrip.style.paddingTop = "0"; + modelStrip.style.alignItems = "flex-end"; + modelStrip.style.pointerEvents = "none"; + modelStrip.style.zIndex = "20"; + shell?.setAttribute("data-model-strip-transition", "true"); + stripAnimation = modelStrip.animate( + [{ height: `${fromHeight}px` }, { height: `${toHeight}px` }], + { duration, easing: COMPOSER_RESTING_TRANSITION_EASING, fill: "both" }, + ); + } + + const animation = element.animate( + [{ height: `${previousHeight}px` }, { height: `${nextHeight}px` }], + { + duration, + easing: COMPOSER_RESTING_TRANSITION_EASING, + fill: "both", + }, + ); + animationRef.current = animation; + animationTargetHeightRef.current = nextHeight; + + const animatedRect = element.getBoundingClientRect(); + const previousBottom = + overlayRect && previousContentOffsetsRef.current.surfaceBottomInset !== null + ? overlayRect.bottom - previousContentOffsetsRef.current.surfaceBottomInset + : animatedRect.bottom; + const bottomShift = previousBottom - animatedRect.bottom; + const previousPromptTop = + interruptedPromptTop ?? + (previousContentOffsetsRef.current.promptFromTop === null + ? null + : animatedRect.top + bottomShift + previousContentOffsetsRef.current.promptFromTop); + const previousActionTop = + interruptedActionTop ?? + (previousContentOffsetsRef.current.actionFromBottom === null + ? null + : previousBottom - previousContentOffsetsRef.current.actionFromBottom); + const contentAnimations: Animation[] = stripAnimation ? [stripAnimation] : []; + const animateContentPosition = ( + content: HTMLElement | null, + previousTop: number | null, + previousLeft: number | null = null, + ) => { + if (!content || previousTop === null) return; + const rect = content.getBoundingClientRect(); + const offset = previousTop - rect.top; + const offsetX = previousLeft === null ? 0 : previousLeft - rect.left; + if (Math.abs(offset) < 0.5 && Math.abs(offsetX) < 0.5) return; + contentAnimations.push( + content.animate( + [{ transform: `translate(${offsetX}px, ${offset}px)` }, { transform: "none" }], + { + duration, + easing: COMPOSER_RESTING_TRANSITION_EASING, + }, + ), + ); + }; + animateContentPosition(prompt, previousPromptTop); + animateContentPosition(action, previousActionTop); + if (continuousControls) { + const previousControlsTop = + interruptedControlsTop ?? + (previousContentOffsetsRef.current.controlsFromBottom === null + ? null + : previousBottom - previousContentOffsetsRef.current.controlsFromBottom); + const previousControlsLeft = + interruptedControlsLeft ?? + (previousContentOffsetsRef.current.controlsFromLeft === null + ? null + : animatedRect.left + previousContentOffsetsRef.current.controlsFromLeft); + animateContentPosition(controls, previousControlsTop, previousControlsLeft); + } + contentAnimationsRef.current = contentAnimations; + + if (stateChanged) { + const stateChangeAnimations: Animation[] = []; + + const previousPromptHeight = previousContentOffsetsRef.current.promptHeight; + if ( + !nextIsCollapsed && + prompt && + nextPromptRect && + previousPromptHeight !== null && + nextPromptRect.height - previousPromptHeight >= 0.5 + ) { + const hiddenHeight = nextPromptRect.height - previousPromptHeight; + stateChangeAnimations.push( + prompt.animate( + [ + { clipPath: `inset(0 0 ${String(hiddenHeight)}px 0)` }, + { clipPath: "inset(0 0 0 0)" }, + ], + { + duration, + easing: COMPOSER_RESTING_TRANSITION_EASING, + }, + ), + ); + } + + const arrivingControls = nextIsCollapsed + ? restingControlsRef.current + : element.querySelector('[data-chat-composer-controls="left"]'); + if (arrivingControls && !continuousControls) { + const drift = nextIsCollapsed + ? -COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX + : COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX; + stateChangeAnimations.push( + arrivingControls.animate( + [ + { opacity: 0, transform: `translateY(${String(drift)}px)` }, + { opacity: 1, transform: "none" }, + ], + { + duration: nextIsCollapsed ? duration : duration / 2, + delay: nextIsCollapsed ? 0 : duration / 2, + fill: "backwards", + easing: COMPOSER_RESTING_TRANSITION_EASING, + }, + ), + ); + } + + const arrivingImagePreviews = nextIsCollapsed + ? Array.from( + element.querySelectorAll('[data-chat-composer-resting-images="true"]'), + ) + : Array.from( + element.querySelectorAll('[data-chat-composer-expanded-image="true"]'), + ); + for (const imagePreview of arrivingImagePreviews) { + stateChangeAnimations.push( + imagePreview.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: nextIsCollapsed ? duration : duration / 2, + delay: nextIsCollapsed ? 0 : duration / 2, + fill: "backwards", + easing: COMPOSER_RESTING_TRANSITION_EASING, + }), + ); + } + stateChangeAnimationsRef.current = stateChangeAnimations; + } + + const finishTransition = (cancelAnimations: boolean) => { + if (animationRef.current !== animation) return; + if (transitionCleanupTimeoutRef.current !== null) { + window.clearTimeout(transitionCleanupTimeoutRef.current); + transitionCleanupTimeoutRef.current = null; + } + if (cancelAnimations) { + animation.cancel(); + for (const contentAnimation of contentAnimationsRef.current) { + contentAnimation.cancel(); + } + for (const stateChangeAnimation of stateChangeAnimationsRef.current) { + stateChangeAnimation.cancel(); + } + } + animationRef.current = null; + animationTargetHeightRef.current = null; + contentAnimationsRef.current = []; + stateChangeAnimationsRef.current = []; + animation.cancel(); + stripAnimation?.cancel(); + clearTransitionStyles(); + }; + void animation.finished.catch(() => undefined).then(() => finishTransition(false)); + // A suspended document timeline can leave `finished` pending while + // these measurement styles remain active. Wall-clock cleanup makes + // the natural layout the eventual source of truth in that case. + transitionCleanupTimeoutRef.current = window.setTimeout( + () => finishTransition(true), + duration + COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS, + ); + } else { + animationTargetHeightRef.current = null; + } + + previousCollapsedRef.current = nextIsCollapsed; + previousHeightRef.current = nextHeight; + previousModelStripHeightRef.current = nextModelStripHeight; + previousContentOffsetsRef.current = { + promptFromTop: nextPromptTop === null ? null : nextPromptTop - nextRect.top, + promptHeight: nextPromptRect?.height ?? null, + actionFromBottom: nextActionTop === null ? null : nextRect.bottom - nextActionTop, + controlsFromBottom: nextControlsTop === null ? null : nextRect.bottom - nextControlsTop, + controlsFromLeft: nextControlsRect === null ? null : nextControlsRect.left - nextRect.left, + surfaceBottomInset: overlayRect ? overlayRect.bottom - nextRect.bottom : null, + }; + }; + + useLayoutEffect(() => { + const requestId = transitionLayoutRequestRef.current + 1; + transitionLayoutRequestRef.current = requestId; + const stateChanged = previousCollapsedRef.current !== isCollapsed; + // A non-Git context strip enters or leaves flow through ChatView state in + // an earlier layout effect. Let React flush that parent update before the + // FLIP reads its destination geometry, while still running before paint. + queueMicrotask(() => { + if (transitionLayoutRequestRef.current !== requestId) return; + transitionToCurrentGeometry(stateChanged); + }); + return () => { + if (transitionLayoutRequestRef.current === requestId) { + transitionLayoutRequestRef.current += 1; + } + }; + }, [isCollapsed, transitionToCurrentGeometry]); + + useLayoutEffect(() => { + if (previousRestingRef.current === isResting) return; + previousRestingRef.current = isResting; + if (animationRef.current) return; + const overlay = elementRef.current?.closest('[data-chat-composer-overlay="true"]'); + if (overlay) onOverlayHeightChange(overlay.getBoundingClientRect().height); + }, [isResting, onOverlayHeightChange]); + + useLayoutEffect(() => { + const element = elementRef.current; + if (!element) return; + + const body = element.querySelector('[data-chat-composer-body="true"]'); + return observeResize(body ? [element, body] : element, (entries) => { + if (animationRef.current) { + if (body && entries.some((entry) => entry.target === body)) { + transitionToCurrentGeometry(false); + } + return; + } + const elementRect = element.getBoundingClientRect(); + const visibleTransitionElement = (selector: string) => + Array.from(element.querySelectorAll(selector)).find( + (candidate) => candidate.getClientRects().length > 0, + ) ?? null; + const promptRect = visibleTransitionElement( + '[data-composer-prompt-surface="true"], [data-chat-composer-transition-prompt="true"]', + )?.getBoundingClientRect(); + const actionTop = visibleTransitionElement( + '[data-chat-composer-transition-actions="true"]', + )?.getBoundingClientRect().top; + const controlsRect = ( + isCollapsedRef.current + ? restingControlsRef.current + : visibleTransitionElement('[data-chat-composer-controls="left"]') + )?.getBoundingClientRect(); + previousHeightRef.current = elementRect.height; + previousContentOffsetsRef.current = { + promptFromTop: promptRect === undefined ? null : promptRect.top - elementRect.top, + promptHeight: promptRect?.height ?? null, + actionFromBottom: actionTop === undefined ? null : elementRect.bottom - actionTop, + controlsFromBottom: + controlsRect === undefined ? null : elementRect.bottom - controlsRect.top, + controlsFromLeft: controlsRect === undefined ? null : controlsRect.left - elementRect.left, + surfaceBottomInset: + (element + .closest('[data-chat-composer-overlay="true"]') + ?.getBoundingClientRect().bottom ?? elementRect.bottom) - elementRect.bottom, + }; + }); + }, [restingControlsRef, transitionToCurrentGeometry]); + + useEffect(() => { + hasCompletedInitialLayoutRef.current = true; + return () => { + if (transitionCleanupTimeoutRef.current !== null) { + window.clearTimeout(transitionCleanupTimeoutRef.current); + transitionCleanupTimeoutRef.current = null; + } + animationRef.current?.cancel(); + animationRef.current = null; + animationTargetHeightRef.current = null; + for (const animation of contentAnimationsRef.current) animation.cancel(); + contentAnimationsRef.current = []; + for (const animation of stateChangeAnimationsRef.current) animation.cancel(); + stateChangeAnimationsRef.current = []; + clearTransitionStyles(); + }; + }, [clearTransitionStyles]); + + return elementRef; +} diff --git a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts new file mode 100644 index 000000000000..bda0641e9091 --- /dev/null +++ b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts @@ -0,0 +1,137 @@ +import { + type EnvironmentId, + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, + type ServerProvider, +} from "@t3tools/contracts"; +import { type ProviderInstanceEntry } from "../../providerInstances"; +import { + hasCompleteProviderWorkspaceSnapshot, + hasCurrentProviderWorkspaceSnapshot, +} from "@t3tools/client-runtime/providerSkills"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useStableCallback } from "../../hooks/useStableCallback"; + +const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; + +/** + * Keeps the selected provider's workspace snapshot fresh for the composer's + * cwd. Returns a check to run when the user types. + */ +export function useProviderWorkspaceSnapshotRefresh(input: { + readonly environmentId: EnvironmentId; + readonly gitCwd: string | null; + readonly selectedProviderEntry: ProviderInstanceEntry | undefined; + readonly selectedProviderStatus: ServerProvider | null; +}) { + const { environmentId, gitCwd, selectedProviderEntry, selectedProviderStatus } = input; + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + // The last scan this composer asked for. A request inside the TTL is not + // repeated, so a client clock ahead of the server's cannot loop rescans. + const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); + const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ + key: string; + notBefore: number; + } | null>(null); + const workspaceRefreshScopeKey = + gitCwd && selectedProviderEntry + ? `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}` + : null; + const workspaceSlashCommandsPending = + selectedProviderStatus?.workspaceSnapshots?.some( + (snapshot) => snapshot.cwd === gitCwd && snapshot.slashCommandsPending === true, + ) ?? false; + useEffect(() => { + if ( + !workspaceSlashCommandsPending || + !workspaceRefreshRetry || + workspaceRefreshRetry.key !== workspaceRefreshScopeKey + ) + return; + const timeout = setTimeout( + () => { + setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); + }, + Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), + ); + return () => clearTimeout(timeout); + }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); + // Losing a snapshot (the server dropped it) lifts both the TTL and the retry + // cooldown so the next check rescans at once. + const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd); + const [hadWorkspaceSnapshot, setHadWorkspaceSnapshot] = useState(hasWorkspaceSnapshot); + const [workspaceSnapshotLossCount, setWorkspaceSnapshotLossCount] = useState(0); + if (hadWorkspaceSnapshot !== hasWorkspaceSnapshot) { + setHadWorkspaceSnapshot(hasWorkspaceSnapshot); + if (!hasWorkspaceSnapshot) { + setWorkspaceSnapshotLossCount((count) => count + 1); + setWorkspaceRefreshRetry(null); + } + } + const lastWorkspaceSnapshotLossRef = useRef(workspaceSnapshotLossCount); + const refreshWorkspaceSnapshotIfStale = ( + now: number, + key: string | null, + retry: { key: string; notBefore: number } | null, + snapshotLossCount: number, + ) => { + if (lastWorkspaceSnapshotLossRef.current !== snapshotLossCount) { + lastWorkspaceSnapshotLossRef.current = snapshotLossCount; + workspaceRefreshKeyRef.current = null; + } + if (key === null || !gitCwd || !selectedProviderEntry) return; + const lastRequest = workspaceRefreshKeyRef.current; + if ( + lastRequest?.key === key && + now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS + ) + return; + // A pending retry needs no clearing here: its cooldown lapses long before the TTL. + if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd, now)) return; + if (retry?.key === key && now < retry.notBefore) return; + const request = { key, requestedAt: now }; + workspaceRefreshKeyRef.current = request; + const retryLater = () => { + if (workspaceRefreshKeyRef.current !== request) return; + workspaceRefreshKeyRef.current = null; + setWorkspaceRefreshRetry({ + key, + notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, + }); + }; + void refreshProviders({ + environmentId, + input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd }, + }).then((result) => { + const refreshedSnapshot = + result._tag === "Success" && + hasCompleteProviderWorkspaceSnapshot( + result.value.providers.find( + (provider) => provider.instanceId === selectedProviderEntry.instanceId, + ), + gitCwd, + ); + if (!refreshedSnapshot) retryLater(); + }, retryLater); + }; + const refreshWorkspaceSnapshotOnChange = useEffectEvent(refreshWorkspaceSnapshotIfStale); + useEffect(() => { + refreshWorkspaceSnapshotOnChange( + Date.now(), + workspaceRefreshScopeKey, + workspaceRefreshRetry, + workspaceSnapshotLossCount, + ); + }, [workspaceRefreshScopeKey, workspaceRefreshRetry, workspaceSnapshotLossCount]); + return useStableCallback(() => + refreshWorkspaceSnapshotIfStale( + Date.now(), + workspaceRefreshScopeKey, + workspaceRefreshRetry, + workspaceSnapshotLossCount, + ), + ); +} diff --git a/apps/web/src/components/chat/useRestingComposerControlsLayout.ts b/apps/web/src/components/chat/useRestingComposerControlsLayout.ts new file mode 100644 index 000000000000..e75aab3a117f --- /dev/null +++ b/apps/web/src/components/chat/useRestingComposerControlsLayout.ts @@ -0,0 +1,84 @@ +import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; +import { observeResize } from "~/lib/observeResize"; +import { resolveRestingComposerControlsLayout } from "../composerFooterLayout"; +import { useLayoutEffect, useRef, useState } from "react"; + +export function useRestingComposerControlsLayout( + host: HTMLDivElement | null, + useControlsAsHost = false, +) { + const [controls, setControls] = useState(null); + const controlsRef = useRef(null); + const attachControls = (element: HTMLDivElement | null) => { + controlsRef.current = element; + setControls(element); + }; + const [layout, setLayout] = useState<{ + hiddenCount: number; + iconOnlyCount?: number; + visible: boolean; + }>({ hiddenCount: 0, visible: true }); + + const measure = () => { + const currentHost = useControlsAsHost ? controls : host; + if (currentHost === null || !controls) return; + + const measurement = measureRestingComposerControls(controls); + if (!measurement) return; + const style = getComputedStyle(currentHost); + const hostWidth = + currentHost.clientWidth - + (Number.parseFloat(style.paddingInlineStart) || 0) - + (Number.parseFloat(style.paddingInlineEnd) || 0); + + setLayout((current) => { + const next = resolveRestingComposerControlsLayout({ + ...measurement, + hostWidth, + previous: current, + }); + return next.hiddenCount === current.hiddenCount && + next.iconOnlyCount === current.iconOnlyCount && + next.visible === current.visible + ? current + : next; + }); + }; + + useLayoutEffect(() => { + const currentHost = useControlsAsHost ? controls : host; + if (!currentHost || !controls) return; + let stopObserving = () => {}; + const observeControls = () => { + stopObserving(); + stopObserving = observeResize( + [ + currentHost, + controls, + ...controls.querySelectorAll( + "[data-resting-block], [data-composer-control-label], [data-chat-provider-model-picker-label]", + ), + ], + measure, + ); + measure(); + }; + observeControls(); + const mutations = new MutationObserver(observeControls); + mutations.observe(controls, { childList: true, subtree: true, characterData: true }); + document.fonts.addEventListener("loadingdone", measure); + return () => { + stopObserving(); + mutations.disconnect(); + document.fonts.removeEventListener("loadingdone", measure); + }; + }, [host, controls, useControlsAsHost, measure]); + + return { + controlsRef, + attachControls, + hiddenBlockCount: layout.hiddenCount, + iconOnlyBlockCount: layout.iconOnlyCount ?? 0, + controlsVisible: layout.visible, + }; +}