| author | |
| committer | |
| log | 5b8445b7a777ab1070aa97b062b1618971073a96 |
| tree | f38e010b51e86e0e23a0d129bf3370f7f4cc345c |
| parent | 24799de4f85a9071afc2420362784ea46041d544 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <maria@kuuro.net>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Claude Code <noreply@anthropic.com>33 files changed, 2297 insertions(+), 292 deletions(-)
apps/web/src/components/BranchToolbar.logic.test.ts+15-1| ... | ... | @@ -430,26 +430,40 @@ describe("shouldShowComposerContextStrip", () => { |
| 430 | 430 | hasActiveProject: true, |
| 431 | 431 | isGitRepo: false, |
| 432 | 432 | showEnvironmentIndicator: true, |
| 433 | hostsRestingComposerControls: false, | |
| 433 | 434 | }), |
| 434 | 435 | ).toBe(true); |
| 435 | 436 | }); |
| 436 | 437 | |
| 437 | it("hides the strip when a non-Git project has no environment indicator", () => { | |
| 438 | it("hides the strip when a non-Git project has nothing to show", () => { | |
| 438 | 439 | expect( |
| 439 | 440 | shouldShowComposerContextStrip({ |
| 440 | 441 | hasActiveProject: true, |
| 441 | 442 | isGitRepo: false, |
| 442 | 443 | showEnvironmentIndicator: false, |
| 444 | hostsRestingComposerControls: false, | |
| 443 | 445 | }), |
| 444 | 446 | ).toBe(false); |
| 445 | 447 | }); |
| 446 | 448 | |
| 449 | it("keeps the strip for visible resting composer controls in a non-Git thread", () => { | |
| 450 | expect( | |
| 451 | shouldShowComposerContextStrip({ | |
| 452 | hasActiveProject: true, | |
| 453 | isGitRepo: false, | |
| 454 | showEnvironmentIndicator: false, | |
| 455 | hostsRestingComposerControls: true, | |
| 456 | }), | |
| 457 | ).toBe(true); | |
| 458 | }); | |
| 459 | ||
| 447 | 460 | it("shows Git controls without requiring an environment indicator", () => { |
| 448 | 461 | expect( |
| 449 | 462 | shouldShowComposerContextStrip({ |
| 450 | 463 | hasActiveProject: true, |
| 451 | 464 | isGitRepo: true, |
| 452 | 465 | showEnvironmentIndicator: false, |
| 466 | hostsRestingComposerControls: false, | |
| 453 | 467 | }), |
| 454 | 468 | ).toBe(true); |
| 455 | 469 | }); |
apps/web/src/components/BranchToolbar.logic.ts+6-1| ... | ... | @@ -59,8 +59,13 @@ export function shouldShowComposerContextStrip(input: { |
| 59 | 59 | hasActiveProject: boolean; |
| 60 | 60 | isGitRepo: boolean; |
| 61 | 61 | showEnvironmentIndicator: boolean; |
| 62 | /** A collapsed composer's controls currently fit in their measured strip host. */ | |
| 63 | hostsRestingComposerControls: boolean; | |
| 62 | 64 | }): boolean { |
| 63 | return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator); | |
| 65 | return ( | |
| 66 | input.hasActiveProject && | |
| 67 | (input.isGitRepo || input.showEnvironmentIndicator || input.hostsRestingComposerControls) | |
| 68 | ); | |
| 64 | 69 | } |
| 65 | 70 | |
| 66 | 71 | export function resolveEnvModeLabel(mode: EnvMode): string { |
apps/web/src/components/BranchToolbar.tsx+82-30| ... | ... | @@ -12,7 +12,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat |
| 12 | 12 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 13 | 13 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 14 | 14 | import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; |
| 15 | import { useIsMobile } from "../hooks/useMediaQuery"; | |
| 16 | 15 | import { |
| 17 | 16 | type EnvMode, |
| 18 | 17 | type EnvironmentOption, |
| ... | ... | @@ -40,6 +39,7 @@ import { |
| 40 | 39 | } from "./ui/menu"; |
| 41 | 40 | import { Separator } from "./ui/separator"; |
| 42 | 41 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 42 | import { cn } from "~/lib/utils"; | |
| 43 | 43 | |
| 44 | 44 | interface BranchToolbarProps { |
| 45 | 45 | environmentId: EnvironmentId; |
| ... | ... | @@ -57,6 +57,8 @@ interface BranchToolbarProps { |
| 57 | 57 | onComposerFocusRequest?: () => void; |
| 58 | 58 | availableEnvironments?: readonly EnvironmentOption[]; |
| 59 | 59 | onEnvironmentChange?: (environmentId: EnvironmentId) => void; |
| 60 | composerControlsHostRef?: (element: HTMLDivElement | null) => void; | |
| 61 | contextStripVisible?: boolean; | |
| 60 | 62 | } |
| 61 | 63 | |
| 62 | 64 | interface MobileRunContextSelectorProps { |
| ... | ... | @@ -120,15 +122,26 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 120 | 122 | const triggerContent = ( |
| 121 | 123 | <> |
| 122 | 124 | {icon} |
| 123 | <span className="min-w-0 truncate"> | |
| 124 | {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} | |
| 125 | <span | |
| 126 | data-composer-label | |
| 127 | className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0" | |
| 128 | > | |
| 129 | <span | |
| 130 | data-composer-label-motion | |
| 131 | className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity" | |
| 132 | > | |
| 133 | {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} | |
| 134 | </span> | |
| 125 | 135 | </span> |
| 126 | 136 | </> |
| 127 | 137 | ); |
| 128 | 138 | |
| 129 | 139 | if (isLocked) { |
| 130 | 140 | return ( |
| 131 | <span className="inline-flex h-7 min-w-0 max-w-[48%] flex-1 items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 md:hidden"> | |
| 141 | <span | |
| 142 | className="inline-flex h-7 min-w-0 max-w-[48%] flex-initial items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6" | |
| 143 | data-composer-context-control | |
| 144 | > | |
| 132 | 145 | {triggerContent} |
| 133 | 146 | </span> |
| 134 | 147 | ); |
| ... | ... | @@ -138,7 +151,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 138 | 151 | <Menu> |
| 139 | 152 | <MenuTrigger |
| 140 | 153 | render={<Button variant="ghost" size="xs" />} |
| 141 | className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" | |
| 154 | className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80" | |
| 155 | data-composer-context-control | |
| 142 | 156 | > |
| 143 | 157 | {triggerContent} |
| 144 | 158 | <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> |
| ... | ... | @@ -251,10 +265,14 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 251 | 265 | let counted = 0; |
| 252 | 266 | for (const child of parent.children) { |
| 253 | 267 | if (!(child instanceof HTMLElement)) continue; |
| 254 | if (child.offsetWidth <= 1) continue; | |
| 255 | const position = getComputedStyle(child).position; | |
| 268 | if (child.offsetWidth === 0) continue; | |
| 269 | const style = getComputedStyle(child); | |
| 270 | const position = style.position; | |
| 256 | 271 | if (position === "absolute" || position === "fixed") continue; |
| 257 | width += child.offsetWidth; | |
| 272 | width += | |
| 273 | child.offsetWidth + | |
| 274 | (Number.parseFloat(style.marginInlineStart) || 0) + | |
| 275 | (Number.parseFloat(style.marginInlineEnd) || 0); | |
| 258 | 276 | counted += 1; |
| 259 | 277 | } |
| 260 | 278 | return width + gap * Math.max(0, counted - 1); |
| ... | ... | @@ -264,10 +282,15 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 264 | 282 | let groups = 0; |
| 265 | 283 | for (const child of current.children) { |
| 266 | 284 | if (!(child instanceof HTMLElement)) continue; |
| 267 | const width = contentWidth(child); | |
| 285 | // The host itself flexes into all remaining room. Measure the controls | |
| 286 | // inside it so Git labels compact before squeezing out the model picker. | |
| 287 | const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]') | |
| 288 | ? child.querySelector<HTMLElement>('[data-chat-composer-resting-controls="true"]') | |
| 289 | : null; | |
| 290 | const width = hostedControls ? contentWidth(hostedControls) : contentWidth(child); | |
| 268 | 291 | if (width <= 1) continue; |
| 269 | needed += width; | |
| 270 | 292 | groups += 1; |
| 293 | needed += width; | |
| 271 | 294 | } |
| 272 | 295 | needed += stripGap * Math.max(0, groups - 1); |
| 273 | 296 | for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) { |
| ... | ... | @@ -390,6 +413,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 390 | 413 | onComposerFocusRequest, |
| 391 | 414 | availableEnvironments, |
| 392 | 415 | onEnvironmentChange, |
| 416 | composerControlsHostRef, | |
| 417 | contextStripVisible = true, | |
| 393 | 418 | }: BranchToolbarProps) { |
| 394 | 419 | const threadRef = useMemo( |
| 395 | 420 | () => scopeThreadRef(environmentId, threadId), |
| ... | ... | @@ -460,7 +485,6 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 460 | 485 | activeEnvironment: activeEnvironmentOption, |
| 461 | 486 | canPickEnvironment: showEnvironmentPicker, |
| 462 | 487 | }); |
| 463 | const isMobile = useIsMobile(); | |
| 464 | 488 | const [stripElement, setStripElement] = useState<HTMLDivElement | null>(null); |
| 465 | 489 | const labelsOverflow = useLabelsOverflow(stripElement); |
| 466 | 490 | |
| ... | ... | @@ -470,24 +494,40 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 470 | 494 | <ComposerSurface.ContextStrip |
| 471 | 495 | ref={setStripElement} |
| 472 | 496 | data-compact={labelsOverflow ? "" : undefined} |
| 497 | className={cn( | |
| 498 | "gap-1 text-xs font-normal text-muted-foreground/70", | |
| 499 | // A non-Git strip with no visible composer controls should occupy no | |
| 500 | // space, but its host must retain a prospective width so controls can | |
| 501 | // become visible again when the chat view grows. | |
| 502 | !contextStripVisible && "pointer-events-none invisible absolute inset-x-0 top-full", | |
| 503 | )} | |
| 473 | 504 | > |
| 474 | {isMobile && showGitControls ? ( | |
| 475 | <MobileRunContextSelector | |
| 476 | envLocked={envLocked} | |
| 477 | envModeLocked={envModeLocked} | |
| 478 | environmentId={environmentId} | |
| 479 | availableEnvironments={availableEnvironments} | |
| 480 | showEnvironmentPicker={showEnvironmentPicker} | |
| 481 | showEnvironmentIndicator={showEnvironmentIndicator} | |
| 482 | onEnvironmentChange={onEnvironmentChange} | |
| 483 | effectiveEnvMode={effectiveEnvMode} | |
| 484 | activeWorktreePath={activeWorktreePath} | |
| 485 | onEnvModeChange={onEnvModeChange} | |
| 486 | previousWorktreeLabel={previousWorktreeLabel} | |
| 487 | onUsePreviousWorktree={onUsePreviousWorktree} | |
| 488 | /> | |
| 489 | ) : ( | |
| 490 | <div className="flex min-w-10 flex-1 items-center gap-1"> | |
| 505 | {showGitControls ? ( | |
| 506 | <div className="contents @3xl/composer-surface:hidden"> | |
| 507 | <MobileRunContextSelector | |
| 508 | envLocked={envLocked} | |
| 509 | envModeLocked={envModeLocked} | |
| 510 | environmentId={environmentId} | |
| 511 | availableEnvironments={availableEnvironments} | |
| 512 | showEnvironmentPicker={showEnvironmentPicker} | |
| 513 | showEnvironmentIndicator={showEnvironmentIndicator} | |
| 514 | onEnvironmentChange={onEnvironmentChange} | |
| 515 | effectiveEnvMode={effectiveEnvMode} | |
| 516 | activeWorktreePath={activeWorktreePath} | |
| 517 | onEnvModeChange={onEnvModeChange} | |
| 518 | previousWorktreeLabel={previousWorktreeLabel} | |
| 519 | onUsePreviousWorktree={onUsePreviousWorktree} | |
| 520 | /> | |
| 521 | </div> | |
| 522 | ) : null} | |
| 523 | {showGitControls || showEnvironmentIndicator ? ( | |
| 524 | <div | |
| 525 | className={cn( | |
| 526 | "min-h-7 min-w-10 items-center gap-1 sm:min-h-6", | |
| 527 | showGitControls ? "hidden @3xl/composer-surface:flex" : "flex", | |
| 528 | composerControlsHostRef ? "shrink" : "flex-1", | |
| 529 | )} | |
| 530 | > | |
| 491 | 531 | {showEnvironmentIndicator && availableEnvironments && ( |
| 492 | 532 | <> |
| 493 | 533 | <BranchToolbarEnvironmentSelector |
| ... | ... | @@ -516,11 +556,23 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 516 | 556 | /> |
| 517 | 557 | ) : null} |
| 518 | 558 | </div> |
| 519 | )} | |
| 559 | ) : null} | |
| 560 | ||
| 561 | {composerControlsHostRef ? ( | |
| 562 | // The host takes whatever the workspace and branch controls leave | |
| 563 | // over, in both strip layouts, so a collapsed composer can show its | |
| 564 | // model and mode controls wherever they fit. | |
| 565 | <div | |
| 566 | ref={composerControlsHostRef} | |
| 567 | data-composer-context-control | |
| 568 | data-chat-resting-composer-controls-host="true" | |
| 569 | className="flex min-w-0 flex-1 items-center justify-start overflow-x-clip overflow-y-visible" | |
| 570 | /> | |
| 571 | ) : null} | |
| 520 | 572 | |
| 521 | 573 | {showGitControls ? ( |
| 522 | 574 | <BranchToolbarBranchSelector |
| 523 | className="min-w-0 flex-1 justify-end md:ml-auto md:flex-initial" | |
| 575 | className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto" | |
| 524 | 576 | environmentId={environmentId} |
| 525 | 577 | threadId={threadId} |
| 526 | 578 | {...(draftId ? { draftId } : {})} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+12-2| ... | ... | @@ -745,7 +745,17 @@ export function BranchToolbarBranchSelector({ |
| 745 | 745 | } |
| 746 | 746 | > |
| 747 | 747 | <ChangeRequestStatusIcon className="size-3" /> |
| 748 | <span>#{branchPr.number}</span> | |
| 748 | <span | |
| 749 | data-composer-label | |
| 750 | className="min-w-0 max-w-12 overflow-hidden group-data-[compact]/composer-context:max-w-0" | |
| 751 | > | |
| 752 | <span | |
| 753 | data-composer-label-motion | |
| 754 | className="block w-full min-w-0 max-w-12 origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity" | |
| 755 | > | |
| 756 | #{branchPr.number} | |
| 757 | </span> | |
| 758 | </span> | |
| 749 | 759 | </TooltipTrigger> |
| 750 | 760 | <TooltipPopup side="top">{branchPrTooltip}</TooltipPopup> |
| 751 | 761 | </Tooltip> |
| ... | ... | @@ -759,7 +769,7 @@ export function BranchToolbarBranchSelector({ |
| 759 | 769 | > |
| 760 | 770 | <ComboboxTrigger |
| 761 | 771 | render={<Button variant="ghost" size="xs" />} |
| 762 | className="min-w-0 max-w-full text-muted-foreground/70 hover:text-foreground/80" | |
| 772 | className="min-w-0 max-w-full font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80" | |
| 763 | 773 | disabled={isInitialBranchesLoadPending || isBranchActionPending} |
| 764 | 774 | > |
| 765 | 775 | <GitBranchIcon className="size-3 shrink-0 opacity-70" /> |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+2-2| ... | ... | @@ -51,7 +51,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 51 | 51 | if (envLocked) { |
| 52 | 52 | return ( |
| 53 | 53 | <span |
| 54 | className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs" | |
| 54 | className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6" | |
| 55 | 55 | data-composer-context-control |
| 56 | 56 | > |
| 57 | 57 | {activeWorktreePath ? ( |
| ... | ... | @@ -90,7 +90,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 90 | 90 | <SelectTrigger |
| 91 | 91 | variant="ghost" |
| 92 | 92 | size="xs" |
| 93 | className="min-w-0 shrink font-medium" | |
| 93 | className="min-w-0 shrink font-normal text-xs!" | |
| 94 | 94 | aria-label="Workspace" |
| 95 | 95 | data-composer-context-control |
| 96 | 96 | > |
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+2-2| ... | ... | @@ -49,7 +49,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 49 | 49 | if (envLocked || onEnvironmentChange === undefined) { |
| 50 | 50 | return ( |
| 51 | 51 | <span |
| 52 | className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs" | |
| 52 | className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6" | |
| 53 | 53 | data-composer-context-control |
| 54 | 54 | > |
| 55 | 55 | <EnvironmentMachineIcon |
| ... | ... | @@ -81,7 +81,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 81 | 81 | <SelectTrigger |
| 82 | 82 | variant="ghost" |
| 83 | 83 | size="xs" |
| 84 | className="min-w-0 max-w-full font-medium" | |
| 84 | className="min-w-0 max-w-full font-normal text-xs!" | |
| 85 | 85 | aria-label="Run on" |
| 86 | 86 | data-composer-context-control |
| 87 | 87 | > |
apps/web/src/components/ChatView.tsx+70-11| ... | ... | @@ -1491,6 +1491,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 1491 | 1491 | const composerElementContextsRef = useRef<ElementContextDraft[]>([]); |
| 1492 | 1492 | const localComposerRef = useRef<ChatComposerHandle | null>(null); |
| 1493 | 1493 | const composerRef = useComposerHandleContext() ?? localComposerRef; |
| 1494 | const [restingComposerControlsHost, setRestingComposerControlsHost] = | |
| 1495 | useState<HTMLDivElement | null>(null); | |
| 1496 | const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); | |
| 1494 | 1497 | const citeAssistantText = useCallback( |
| 1495 | 1498 | (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => { |
| 1496 | 1499 | const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false; |
| ... | ... | @@ -1581,10 +1584,16 @@ function ChatViewContent(props: ChatViewProps) { |
| 1581 | 1584 | LastInvokedScriptByProjectSchema, |
| 1582 | 1585 | ); |
| 1583 | 1586 | const legendListRef = useRef<LegendListRef | null>(null); |
| 1587 | const getTimelineScrollableNode = useCallback( | |
| 1588 | () => legendListRef.current?.getScrollableNode() ?? null, | |
| 1589 | [], | |
| 1590 | ); | |
| 1584 | 1591 | const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null); |
| 1585 | 1592 | const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); |
| 1593 | const composerOverlayHeightRef = useRef(0); | |
| 1586 | 1594 | const [scrollToEndClearance, setScrollToEndClearance] = useState(0); |
| 1587 | 1595 | const isAtEndRef = useRef(true); |
| 1596 | const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); | |
| 1588 | 1597 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 1589 | 1598 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); |
| 1590 | 1599 | const sendInFlightRef = useRef(false); |
| ... | ... | @@ -3000,10 +3009,20 @@ function ChatViewContent(props: ChatViewProps) { |
| 3000 | 3009 | terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; |
| 3001 | 3010 | // Default true while loading to avoid toolbar flicker. |
| 3002 | 3011 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; |
| 3012 | // Keep a hidden, off-flow strip mounted for existing threads so the composer | |
| 3013 | // can measure whether its relocated controls fit. The visible chrome remains | |
| 3014 | // content-driven: Git/environment context or controls that actually fit. | |
| 3015 | const mountComposerContextStrip = shouldShowComposerContextStrip({ | |
| 3016 | hasActiveProject: activeProject !== null, | |
| 3017 | isGitRepo, | |
| 3018 | showEnvironmentIndicator: showComposerEnvironmentIndicator, | |
| 3019 | hostsRestingComposerControls: routeKind === "server", | |
| 3020 | }); | |
| 3003 | 3021 | const showComposerContextStrip = shouldShowComposerContextStrip({ |
| 3004 | 3022 | hasActiveProject: activeProject !== null, |
| 3005 | 3023 | isGitRepo, |
| 3006 | 3024 | showEnvironmentIndicator: showComposerEnvironmentIndicator, |
| 3025 | hostsRestingComposerControls: routeKind === "server" && restingComposerControlsVisible, | |
| 3007 | 3026 | }); |
| 3008 | 3027 | const initialDiffPanelGitScope = |
| 3009 | 3028 | gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; |
| ... | ... | @@ -4336,6 +4355,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4336 | 4355 | const showScrollDebouncer = useRef( |
| 4337 | 4356 | new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), |
| 4338 | 4357 | ); |
| 4358 | const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null); | |
| 4339 | 4359 | const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end"); |
| 4340 | 4360 | // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd |
| 4341 | 4361 | // re-pins on its own (independent of the refs), so the timeline needs a |
| ... | ... | @@ -4520,6 +4540,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 4520 | 4540 | // Only an upward wheel is a navigation intent; wheeling down while |
| 4521 | 4541 | // following either does nothing (at the end) or moves toward it. |
| 4522 | 4542 | const handleWheel = (event: WheelEvent) => { |
| 4543 | if (event.deltaY > 0) { | |
| 4544 | timelineScrollIntentRef.current = "toward-end"; | |
| 4545 | if (isAtEndRef.current) { | |
| 4546 | composerRef.current?.restoreAfterTimelineReachedEnd(); | |
| 4547 | } | |
| 4548 | } else if (event.deltaY < 0) { | |
| 4549 | timelineScrollIntentRef.current = "away-from-end"; | |
| 4550 | } | |
| 4523 | 4551 | if ( |
| 4524 | 4552 | event.deltaY < 0 && |
| 4525 | 4553 | contentScrollsUp() && |
| ... | ... | @@ -4561,10 +4589,16 @@ function ChatViewContent(props: ChatViewProps) { |
| 4561 | 4589 | case "PageUp": |
| 4562 | 4590 | case "Home": |
| 4563 | 4591 | case "ArrowUp": |
| 4592 | timelineScrollIntentRef.current = "away-from-end"; | |
| 4564 | 4593 | if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) { |
| 4565 | 4594 | handleManualNavigation(); |
| 4566 | 4595 | } |
| 4567 | 4596 | break; |
| 4597 | case "PageDown": | |
| 4598 | case "End": | |
| 4599 | case "ArrowDown": | |
| 4600 | timelineScrollIntentRef.current = "toward-end"; | |
| 4601 | break; | |
| 4568 | 4602 | default: |
| 4569 | 4603 | break; |
| 4570 | 4604 | } |
| ... | ... | @@ -4655,6 +4689,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 4655 | 4689 | if (isAtEndRef.current === isAtEnd) return; |
| 4656 | 4690 | isAtEndRef.current = isAtEnd; |
| 4657 | 4691 | if (isAtEnd) { |
| 4692 | if (timelineScrollIntentRef.current === "toward-end") { | |
| 4693 | composerRef.current?.restoreAfterTimelineReachedEnd(); | |
| 4694 | } | |
| 4658 | 4695 | timelineScrollModeRef.current = "following-end"; |
| 4659 | 4696 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; |
| 4660 | 4697 | setTimelineLiveFollowEnabled(true); |
| ... | ... | @@ -4727,6 +4764,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4727 | 4764 | useEffect(() => { |
| 4728 | 4765 | setPullRequestDialogState(null); |
| 4729 | 4766 | isAtEndRef.current = true; |
| 4767 | timelineScrollIntentRef.current = null; | |
| 4730 | 4768 | timelineScrollModeRef.current = "following-end"; |
| 4731 | 4769 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; |
| 4732 | 4770 | setTimelineLiveFollowEnabled(true); |
| ... | ... | @@ -4856,18 +4894,25 @@ function ChatViewContent(props: ChatViewProps) { |
| 4856 | 4894 | activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId |
| 4857 | 4895 | ? activePlan.steps |
| 4858 | 4896 | : null; |
| 4897 | ||
| 4898 | const publishComposerOverlayHeight = useCallback((height: number) => { | |
| 4899 | const nextHeight = Math.ceil(height); | |
| 4900 | if (nextHeight <= 0) return; | |
| 4901 | const previousHeight = composerOverlayHeightRef.current; | |
| 4902 | if (previousHeight !== nextHeight) { | |
| 4903 | composerOverlayHeightRef.current = nextHeight; | |
| 4904 | setComposerOverlayHeight(nextHeight); | |
| 4905 | } | |
| 4906 | setScrollToEndClearance((currentClearance) => | |
| 4907 | currentClearance === nextHeight ? currentClearance : nextHeight, | |
| 4908 | ); | |
| 4909 | }, []); | |
| 4910 | ||
| 4859 | 4911 | useLayoutEffect(() => { |
| 4860 | 4912 | if (!composerOverlayElement) return; |
| 4861 | 4913 | |
| 4862 | 4914 | const updateHeight = () => { |
| 4863 | const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); | |
| 4864 | if (nextHeight <= 0) return; | |
| 4865 | setComposerOverlayHeight((currentHeight) => | |
| 4866 | currentHeight === nextHeight ? currentHeight : nextHeight, | |
| 4867 | ); | |
| 4868 | setScrollToEndClearance((currentClearance) => | |
| 4869 | currentClearance === nextHeight ? currentClearance : nextHeight, | |
| 4870 | ); | |
| 4915 | publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); | |
| 4871 | 4916 | }; |
| 4872 | 4917 | |
| 4873 | 4918 | updateHeight(); |
| ... | ... | @@ -4878,7 +4923,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4878 | 4923 | return () => { |
| 4879 | 4924 | resizeObserver.disconnect(); |
| 4880 | 4925 | }; |
| 4881 | }, [composerOverlayElement]); | |
| 4926 | }, [composerOverlayElement, publishComposerOverlayHeight]); | |
| 4882 | 4927 | const activeThreadPr = |
| 4883 | 4928 | replacementLinkedThreadPullRequest !== null |
| 4884 | 4929 | ? (gitStatusQuery.data?.pr ?? null) |
| ... | ... | @@ -7638,7 +7683,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 7638 | 7683 | > |
| 7639 | 7684 | <Button |
| 7640 | 7685 | aria-label="Scroll to end" |
| 7641 | onClick={() => scrollToEnd(true)} | |
| 7686 | onPointerDown={(event) => event.preventDefault()} | |
| 7687 | onClick={() => { | |
| 7688 | composerRef.current?.restoreAfterTimelineReachedEnd(); | |
| 7689 | scrollToEnd(true); | |
| 7690 | }} | |
| 7642 | 7691 | className="pointer-events-auto gap-1.5 rounded-full px-3 text-muted-foreground hover:text-foreground" |
| 7643 | 7692 | size="xs" |
| 7644 | 7693 | variant="glass" |
| ... | ... | @@ -7756,6 +7805,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 7756 | 7805 | keybindings={keybindings} |
| 7757 | 7806 | terminalOpen={Boolean(terminalUiState.terminalOpen)} |
| 7758 | 7807 | gitCwd={gitCwd} |
| 7808 | restingControlsHost={restingComposerControlsHost} | |
| 7809 | restingControlsHaveLeadingContext={ | |
| 7810 | isGitRepo || showComposerEnvironmentIndicator | |
| 7811 | } | |
| 7812 | onRestingControlsVisibilityChange={setRestingComposerControlsVisible} | |
| 7813 | getTimelineScrollableNode={getTimelineScrollableNode} | |
| 7814 | isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} | |
| 7815 | onComposerOverlayHeightChange={publishComposerOverlayHeight} | |
| 7759 | 7816 | promptRef={promptRef} |
| 7760 | 7817 | composerImagesRef={composerImagesRef} |
| 7761 | 7818 | composerFilesRef={composerFilesRef} |
| ... | ... | @@ -7796,7 +7853,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7796 | 7853 | data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined} |
| 7797 | 7854 | className="relative z-0" |
| 7798 | 7855 | > |
| 7799 | {showComposerContextStrip && ( | |
| 7856 | {mountComposerContextStrip && ( | |
| 7800 | 7857 | <div className="pointer-events-auto"> |
| 7801 | 7858 | <BranchToolbar |
| 7802 | 7859 | environmentId={activeThread.environmentId} |
| ... | ... | @@ -7823,6 +7880,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 7823 | 7880 | : {})} |
| 7824 | 7881 | {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} |
| 7825 | 7882 | availableEnvironments={logicalProjectEnvironments} |
| 7883 | composerControlsHostRef={setRestingComposerControlsHost} | |
| 7884 | contextStripVisible={showComposerContextStrip} | |
| 7826 | 7885 | /> |
| 7827 | 7886 | </div> |
| 7828 | 7887 | )} |
apps/web/src/components/ComposerPromptEditor.tsx+61-7| ... | ... | @@ -87,6 +87,7 @@ import { getTimelinePageScrollKey } from "./chat/pageScrollController"; |
| 87 | 87 | import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; |
| 88 | 88 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 89 | 89 | import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; |
| 90 | import { didComposerSelectionChangeVisibly } from "./composerSelection"; | |
| 90 | 91 | import { |
| 91 | 92 | $consumeComposerCitationCommentRequest, |
| 92 | 93 | $createComposerCitationNode, |
| ... | ... | @@ -904,7 +905,9 @@ interface ComposerPromptEditorProps { |
| 904 | 905 | skills: ReadonlyArray<ServerProviderSkill>; |
| 905 | 906 | disabled: boolean; |
| 906 | 907 | placeholder: string; |
| 908 | containerClassName?: string; | |
| 907 | 909 | className?: string; |
| 910 | placeholderClassName?: string; | |
| 908 | 911 | onRemoveTerminalContext: (contextId: string) => void; |
| 909 | 912 | onChange: ( |
| 910 | 913 | nextValue: string, |
| ... | ... | @@ -913,6 +916,7 @@ interface ComposerPromptEditorProps { |
| 913 | 916 | cursorAdjacentToMention: boolean, |
| 914 | 917 | terminalContextIds: string[], |
| 915 | 918 | ) => void; |
| 919 | onVisibleSelectionChange?: () => void; | |
| 916 | 920 | onCommandKeyDown?: ( |
| 917 | 921 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", |
| 918 | 922 | event: KeyboardEvent, |
| ... | ... | @@ -1557,9 +1561,12 @@ function ComposerPromptEditorInner({ |
| 1557 | 1561 | skills, |
| 1558 | 1562 | disabled, |
| 1559 | 1563 | placeholder, |
| 1564 | containerClassName, | |
| 1560 | 1565 | className, |
| 1566 | placeholderClassName, | |
| 1561 | 1567 | onRemoveTerminalContext, |
| 1562 | 1568 | onChange, |
| 1569 | onVisibleSelectionChange, | |
| 1563 | 1570 | onCommandKeyDown, |
| 1564 | 1571 | onPageScrollKeyDown, |
| 1565 | 1572 | onPageScrollKeyUp, |
| ... | ... | @@ -1569,7 +1576,9 @@ function ComposerPromptEditorInner({ |
| 1569 | 1576 | }: ComposerPromptEditorProps) { |
| 1570 | 1577 | const [editor] = useLexicalComposerContext(); |
| 1571 | 1578 | const onChangeRef = useRef(onChange); |
| 1579 | const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); | |
| 1572 | 1580 | const initialCursor = clampCollapsedComposerCursor(value, cursor); |
| 1581 | const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); | |
| 1573 | 1582 | const terminalContextsSignature = terminalContextSignature(terminalContexts); |
| 1574 | 1583 | const terminalContextsSignatureRef = useRef(terminalContextsSignature); |
| 1575 | 1584 | const skillsSignature = skillSignature(skills); |
| ... | ... | @@ -1578,9 +1587,10 @@ function ComposerPromptEditorInner({ |
| 1578 | 1587 | const snapshotRef = useRef({ |
| 1579 | 1588 | value, |
| 1580 | 1589 | cursor: initialCursor, |
| 1581 | expandedCursor: expandCollapsedComposerCursor(value, initialCursor), | |
| 1590 | expandedCursor: initialExpandedCursor, | |
| 1582 | 1591 | terminalContextIds: terminalContexts.map((context) => context.id), |
| 1583 | 1592 | }); |
| 1593 | const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); | |
| 1584 | 1594 | const isApplyingControlledUpdateRef = useRef(false); |
| 1585 | 1595 | const citationCommentRequestRef = useRef<ComposerCitationCommentRequest | null>(null); |
| 1586 | 1596 | const [openCitationComment, setOpenCitationComment] = |
| ... | ... | @@ -1605,6 +1615,10 @@ function ComposerPromptEditorInner({ |
| 1605 | 1615 | onChangeRef.current = onChange; |
| 1606 | 1616 | }, [onChange]); |
| 1607 | 1617 | |
| 1618 | useEffect(() => { | |
| 1619 | onVisibleSelectionChangeRef.current = onVisibleSelectionChange; | |
| 1620 | }, [onVisibleSelectionChange]); | |
| 1621 | ||
| 1608 | 1622 | useLayoutEffect(() => { |
| 1609 | 1623 | skillMetadataRef.current = skillMetadataByName(skills); |
| 1610 | 1624 | }, [skills]); |
| ... | ... | @@ -1643,12 +1657,17 @@ function ComposerPromptEditorInner({ |
| 1643 | 1657 | return; |
| 1644 | 1658 | } |
| 1645 | 1659 | |
| 1660 | const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); | |
| 1646 | 1661 | snapshotRef.current = { |
| 1647 | 1662 | value, |
| 1648 | 1663 | cursor: normalizedCursor, |
| 1649 | expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), | |
| 1664 | expandedCursor: normalizedExpandedCursor, | |
| 1650 | 1665 | terminalContextIds: terminalContexts.map((context) => context.id), |
| 1651 | 1666 | }; |
| 1667 | selectionRangeRef.current = { | |
| 1668 | start: normalizedExpandedCursor, | |
| 1669 | end: normalizedExpandedCursor, | |
| 1670 | }; | |
| 1652 | 1671 | terminalContextsSignatureRef.current = terminalContextsSignature; |
| 1653 | 1672 | skillsSignatureRef.current = skillsSignature; |
| 1654 | 1673 | |
| ... | ... | @@ -1700,6 +1719,10 @@ function ComposerPromptEditorInner({ |
| 1700 | 1719 | expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), |
| 1701 | 1720 | terminalContextIds: snapshotRef.current.terminalContextIds, |
| 1702 | 1721 | }; |
| 1722 | selectionRangeRef.current = { | |
| 1723 | start: snapshotRef.current.expandedCursor, | |
| 1724 | end: snapshotRef.current.expandedCursor, | |
| 1725 | }; | |
| 1703 | 1726 | onChangeRef.current( |
| 1704 | 1727 | snapshotRef.current.value, |
| 1705 | 1728 | boundedCursor, |
| ... | ... | @@ -1733,6 +1756,7 @@ function ComposerPromptEditorInner({ |
| 1733 | 1756 | nextValue, |
| 1734 | 1757 | $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), |
| 1735 | 1758 | ); |
| 1759 | const selectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); | |
| 1736 | 1760 | const terminalContextIds = collectTerminalContextIds($getRoot()); |
| 1737 | 1761 | snapshot = { |
| 1738 | 1762 | value: nextValue, |
| ... | ... | @@ -1740,6 +1764,10 @@ function ComposerPromptEditorInner({ |
| 1740 | 1764 | expandedCursor: nextExpandedCursor, |
| 1741 | 1765 | terminalContextIds, |
| 1742 | 1766 | }; |
| 1767 | selectionRangeRef.current = selectionRange ?? { | |
| 1768 | start: nextExpandedCursor, | |
| 1769 | end: nextExpandedCursor, | |
| 1770 | }; | |
| 1743 | 1771 | }); |
| 1744 | 1772 | snapshotRef.current = snapshot; |
| 1745 | 1773 | return snapshot; |
| ... | ... | @@ -1788,18 +1816,28 @@ function ComposerPromptEditorInner({ |
| 1788 | 1816 | nextValue, |
| 1789 | 1817 | $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), |
| 1790 | 1818 | ); |
| 1819 | const nextSelectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); | |
| 1820 | const previousSelectionRange = selectionRangeRef.current; | |
| 1821 | selectionRangeRef.current = nextSelectionRange ?? { | |
| 1822 | start: nextExpandedCursor, | |
| 1823 | end: nextExpandedCursor, | |
| 1824 | }; | |
| 1791 | 1825 | const terminalContextIds = collectTerminalContextIds($getRoot()); |
| 1792 | 1826 | const previousSnapshot = snapshotRef.current; |
| 1793 | if ( | |
| 1827 | const snapshotChanged = !( | |
| 1794 | 1828 | previousSnapshot.value === nextValue && |
| 1795 | 1829 | previousSnapshot.cursor === nextCursor && |
| 1796 | 1830 | previousSnapshot.expandedCursor === nextExpandedCursor && |
| 1797 | 1831 | previousSnapshot.terminalContextIds.length === terminalContextIds.length && |
| 1798 | 1832 | previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) |
| 1799 | ) { | |
| 1833 | ); | |
| 1834 | if (isApplyingControlledUpdateRef.current) { | |
| 1800 | 1835 | return; |
| 1801 | 1836 | } |
| 1802 | if (isApplyingControlledUpdateRef.current) { | |
| 1837 | if (!snapshotChanged) { | |
| 1838 | if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { | |
| 1839 | onVisibleSelectionChangeRef.current?.(); | |
| 1840 | } | |
| 1803 | 1841 | return; |
| 1804 | 1842 | } |
| 1805 | 1843 | snapshotRef.current = { |
| ... | ... | @@ -1824,7 +1862,12 @@ function ComposerPromptEditorInner({ |
| 1824 | 1862 | return ( |
| 1825 | 1863 | <ComposerTerminalContextActionsContext value={terminalContextActions}> |
| 1826 | 1864 | <ComposerCitationCommentContext value={citationCommentActions}> |
| 1827 | <div className="relative [font-family:var(--font-composer,var(--font-sans))] [font-size:var(--font-size-prompt,0.875rem)] [@media(max-width:39.999rem)_and_(pointer:coarse)]:[font-size:max(var(--font-size-prompt,1rem),16px)]"> | |
| 1865 | <div | |
| 1866 | className={cn( | |
| 1867 | "relative [font-family:var(--font-composer,var(--font-sans))] [font-size:var(--font-size-prompt,0.875rem)] [@media(max-width:39.999rem)_and_(pointer:coarse)]:[font-size:max(var(--font-size-prompt,1rem),16px)]", | |
| 1868 | containerClassName, | |
| 1869 | )} | |
| 1870 | > | |
| 1828 | 1871 | <PlainTextPlugin |
| 1829 | 1872 | contentEditable={ |
| 1830 | 1873 | <ContentEditable |
| ... | ... | @@ -1881,7 +1924,12 @@ function ComposerPromptEditorInner({ |
| 1881 | 1924 | } |
| 1882 | 1925 | placeholder={ |
| 1883 | 1926 | terminalContexts.length > 0 ? null : ( |
| 1884 | <div className="pointer-events-none absolute inset-0 leading-relaxed text-placeholder"> | |
| 1927 | <div | |
| 1928 | className={cn( | |
| 1929 | "pointer-events-none absolute inset-0 leading-relaxed text-placeholder", | |
| 1930 | placeholderClassName, | |
| 1931 | )} | |
| 1932 | > | |
| 1885 | 1933 | {placeholder} |
| 1886 | 1934 | </div> |
| 1887 | 1935 | ) |
| ... | ... | @@ -1911,9 +1959,12 @@ export function ComposerPromptEditor({ |
| 1911 | 1959 | skills, |
| 1912 | 1960 | disabled, |
| 1913 | 1961 | placeholder, |
| 1962 | containerClassName, | |
| 1914 | 1963 | className, |
| 1964 | placeholderClassName, | |
| 1915 | 1965 | onRemoveTerminalContext, |
| 1916 | 1966 | onChange, |
| 1967 | onVisibleSelectionChange, | |
| 1917 | 1968 | onCommandKeyDown, |
| 1918 | 1969 | onPageScrollKeyDown, |
| 1919 | 1970 | onPageScrollKeyUp, |
| ... | ... | @@ -1957,8 +2008,10 @@ export function ComposerPromptEditor({ |
| 1957 | 2008 | skills={skills} |
| 1958 | 2009 | disabled={disabled} |
| 1959 | 2010 | placeholder={placeholder} |
| 2011 | {...(containerClassName ? { containerClassName } : {})} | |
| 1960 | 2012 | onRemoveTerminalContext={onRemoveTerminalContext} |
| 1961 | 2013 | onChange={onChange} |
| 2014 | {...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})} | |
| 1962 | 2015 | onPaste={onPaste} |
| 1963 | 2016 | editorRef={editorRef} |
| 1964 | 2017 | {...(onCommandKeyDown ? { onCommandKeyDown } : {})} |
| ... | ... | @@ -1966,6 +2019,7 @@ export function ComposerPromptEditor({ |
| 1966 | 2019 | {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})} |
| 1967 | 2020 | {...(onPageScrollRelease ? { onPageScrollRelease } : {})} |
| 1968 | 2021 | {...(className ? { className } : {})} |
| 2022 | {...(placeholderClassName ? { placeholderClassName } : {})} | |
| 1969 | 2023 | /> |
| 1970 | 2024 | </LexicalComposer> |
| 1971 | 2025 | ); |
apps/web/src/components/chat/AssistantCitationChip.tsx+2| ... | ... | @@ -23,6 +23,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 23 | 23 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 24 | 24 | import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; |
| 25 | 25 | import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; |
| 26 | import { composerFloatingLayerProps } from "./composerEventScope"; | |
| 26 | 27 | |
| 27 | 28 | const CITATION_ACTION_BUTTON_CLASS_NAME = cn( |
| 28 | 29 | COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, |
| ... | ... | @@ -135,6 +136,7 @@ export function AssistantCitationChip({ |
| 135 | 136 | </PopoverTrigger> |
| 136 | 137 | {commentEditor.open ? ( |
| 137 | 138 | <PopoverPopup |
| 139 | {...composerFloatingLayerProps} | |
| 138 | 140 | side={sourceAnchor ? "bottom" : "top"} |
| 139 | 141 | align="end" |
| 140 | 142 | anchor={popupAnchor} |
apps/web/src/components/chat/ChatComposer.tsx+1200-128| ... | ... | @@ -23,6 +23,7 @@ import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/ |
| 23 | 23 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 24 | 24 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 25 | 25 | import { |
| 26 | Fragment, | |
| 26 | 27 | memo, |
| 27 | 28 | type ReactNode, |
| 28 | 29 | useCallback, |
| ... | ... | @@ -51,6 +52,11 @@ import { |
| 51 | 52 | dataTransferHasComposerMention, |
| 52 | 53 | makeComposerMentionDragHandlers, |
| 53 | 54 | } from "./composerMentionDrag"; |
| 55 | import { | |
| 56 | composerFloatingLayerProps, | |
| 57 | isInsideComposerFloatingLayer, | |
| 58 | isInsideRestingComposerControlScope, | |
| 59 | } from "./composerEventScope"; | |
| 54 | 60 | import { |
| 55 | 61 | type ComposerFileAttachment, |
| 56 | 62 | type ComposerImageAttachment, |
| ... | ... | @@ -137,8 +143,12 @@ import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards |
| 137 | 143 | import { |
| 138 | 144 | COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, |
| 139 | 145 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, |
| 146 | getRestingComposerImagePreviewCounts, | |
| 147 | resolveRestingComposerControlsLayout, | |
| 148 | shouldAnimateComposerRestingTransition, | |
| 140 | 149 | shouldUseCompactComposerPrimaryActions, |
| 141 | 150 | shouldUseCompactComposerFooter, |
| 151 | shouldUseRestingComposerLayout, | |
| 142 | 152 | } from "../composerFooterLayout"; |
| 143 | 153 | import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; |
| 144 | 154 | import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; |
| ... | ... | @@ -150,7 +160,12 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; |
| 150 | 160 | import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; |
| 151 | 161 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; |
| 152 | 162 | import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; |
| 153 | import { ComposerControl, ComposerControlIcon, ComposerSelectControl } from "./ComposerControl"; | |
| 163 | import { | |
| 164 | ComposerControl, | |
| 165 | ComposerControlIcon, | |
| 166 | ComposerControlSeparator, | |
| 167 | ComposerSelectControl, | |
| 168 | } from "./ComposerControl"; | |
| 154 | 169 | import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; |
| 155 | 170 | import { |
| 156 | 171 | searchSlashCommandItems, |
| ... | ... | @@ -171,13 +186,18 @@ import { |
| 171 | 186 | } from "./ExpandedImagePreview"; |
| 172 | 187 | import { basenameOfPath } from "../../pierre-icons"; |
| 173 | 188 | import { cn, randomUUID } from "~/lib/utils"; |
| 174 | import { Separator } from "../ui/separator"; | |
| 175 | 189 | import { |
| 176 | 190 | getComposerPromptLengthValidationMessage, |
| 177 | 191 | getComposerSubmissionValidationMessage, |
| 178 | 192 | submitComposerDraft, |
| 179 | 193 | } from "./composerSubmission"; |
| 180 | 194 | import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; |
| 195 | import { | |
| 196 | createComposerScrollGestureState, | |
| 197 | recordComposerScrollGestureEvent, | |
| 198 | resetComposerScrollGesture, | |
| 199 | suppressActiveComposerScrollGesture, | |
| 200 | } from "./composerScrollGesture"; | |
| 181 | 201 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; |
| 182 | 202 | |
| 183 | 203 | function ComposerVideoThumbnail({ file }: { file: File }) { |
| ... | ... | @@ -209,6 +229,435 @@ type ComposerCommandMenuPosition = { |
| 209 | 229 | width: number; |
| 210 | 230 | }; |
| 211 | 231 | |
| 232 | const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; | |
| 233 | const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; | |
| 234 | const COMPOSER_RESTING_TRANSITION_DURATION_MS = 280; | |
| 235 | const COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS = 50; | |
| 236 | const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; | |
| 237 | const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; | |
| 238 | ||
| 239 | function useComposerRestingTransition( | |
| 240 | isCollapsed: boolean, | |
| 241 | restingControlsRef: React.RefObject<HTMLDivElement | null>, | |
| 242 | onOverlayHeightChange: (height: number) => void, | |
| 243 | ) { | |
| 244 | const elementRef = useRef<HTMLDivElement>(null); | |
| 245 | const isCollapsedRef = useRef(isCollapsed); | |
| 246 | const previousCollapsedRef = useRef(isCollapsed); | |
| 247 | const previousHeightRef = useRef<number | null>(null); | |
| 248 | const previousContentOffsetsRef = useRef<{ | |
| 249 | promptFromTop: number | null; | |
| 250 | promptHeight: number | null; | |
| 251 | actionFromBottom: number | null; | |
| 252 | }>({ promptFromTop: null, promptHeight: null, actionFromBottom: null }); | |
| 253 | const animationRef = useRef<Animation | null>(null); | |
| 254 | const animationTargetHeightRef = useRef<number | null>(null); | |
| 255 | const contentAnimationsRef = useRef<Animation[]>([]); | |
| 256 | const stateChangeAnimationsRef = useRef<Animation[]>([]); | |
| 257 | const pinnedOverlayRef = useRef<HTMLElement | null>(null); | |
| 258 | const transitionCleanupTimeoutRef = useRef<number | null>(null); | |
| 259 | const transitionLayoutRequestRef = useRef(0); | |
| 260 | const hasCompletedInitialLayoutRef = useRef(false); | |
| 261 | ||
| 262 | const clearOverlayPin = useCallback(() => { | |
| 263 | // The overlay belongs to the chat view and outlives this composer, so it | |
| 264 | // is remembered from pin time rather than re-resolved through a ref that | |
| 265 | // React may already have detached during unmount. | |
| 266 | const overlay = pinnedOverlayRef.current; | |
| 267 | pinnedOverlayRef.current = null; | |
| 268 | overlay?.style.removeProperty("height"); | |
| 269 | overlay?.style.removeProperty("display"); | |
| 270 | overlay?.style.removeProperty("flex-direction"); | |
| 271 | overlay?.style.removeProperty("justify-content"); | |
| 272 | }, []); | |
| 273 | ||
| 274 | const clearTransitionStyles = useCallback(() => { | |
| 275 | const element = elementRef.current; | |
| 276 | const footer = element?.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'); | |
| 277 | element?.style.removeProperty("overflow"); | |
| 278 | element | |
| 279 | ?.querySelector<HTMLElement>('[data-chat-composer-surface="true"]') | |
| 280 | ?.style.removeProperty("height"); | |
| 281 | footer?.style.removeProperty("position"); | |
| 282 | footer?.style.removeProperty("top"); | |
| 283 | footer?.style.removeProperty("bottom"); | |
| 284 | footer?.style.removeProperty("left"); | |
| 285 | footer?.style.removeProperty("right"); | |
| 286 | footer?.style.removeProperty("height"); | |
| 287 | clearOverlayPin(); | |
| 288 | }, [clearOverlayPin]); | |
| 289 | ||
| 290 | isCollapsedRef.current = isCollapsed; | |
| 291 | ||
| 292 | const transitionToCurrentGeometry = useCallback( | |
| 293 | (stateChanged: boolean) => { | |
| 294 | const element = elementRef.current; | |
| 295 | const surface = element?.querySelector<HTMLElement>('[data-chat-composer-surface="true"]'); | |
| 296 | if (!element || !surface) return; | |
| 297 | ||
| 298 | const nextIsCollapsed = isCollapsedRef.current; | |
| 299 | ||
| 300 | const visibleTransitionElement = (selector: string) => | |
| 301 | Array.from(element.querySelectorAll<HTMLElement>(selector)).find( | |
| 302 | (candidate) => candidate.getClientRects().length > 0, | |
| 303 | ) ?? null; | |
| 304 | const prompt = visibleTransitionElement( | |
| 305 | '[data-testid="composer-editor"], [data-chat-composer-transition-prompt="true"]', | |
| 306 | ); | |
| 307 | const action = visibleTransitionElement('[data-chat-composer-transition-actions="true"]'); | |
| 308 | const footer = element.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'); | |
| 309 | const interruptedAnimation = animationRef.current; | |
| 310 | const interruptedPromptTop = interruptedAnimation | |
| 311 | ? (prompt?.getBoundingClientRect().top ?? null) | |
| 312 | : null; | |
| 313 | const interruptedActionTop = interruptedAnimation | |
| 314 | ? (action?.getBoundingClientRect().top ?? null) | |
| 315 | : null; | |
| 316 | const interruptedHeight = interruptedAnimation | |
| 317 | ? element.getBoundingClientRect().height | |
| 318 | : null; | |
| 319 | const interruptedTargetHeight = animationTargetHeightRef.current; | |
| 320 | const interruptedCurrentTime = | |
| 321 | typeof interruptedAnimation?.currentTime === "number" | |
| 322 | ? interruptedAnimation.currentTime | |
| 323 | : null; | |
| 324 | const interruptedDuration = interruptedAnimation?.effect?.getComputedTiming().duration; | |
| 325 | if (transitionCleanupTimeoutRef.current !== null) { | |
| 326 | window.clearTimeout(transitionCleanupTimeoutRef.current); | |
| 327 | transitionCleanupTimeoutRef.current = null; | |
| 328 | } | |
| 329 | interruptedAnimation?.cancel(); | |
| 330 | animationRef.current = null; | |
| 331 | for (const animation of contentAnimationsRef.current) animation.cancel(); | |
| 332 | contentAnimationsRef.current = []; | |
| 333 | // The reveal and fade animations keep their own schedule across the | |
| 334 | // body-resize re-entries that retarget the geometry mid-flight (every | |
| 335 | // transition with a draft triggers one); cancelling them there would | |
| 336 | // pop their subjects to full visibility at the start of the tween. | |
| 337 | if (stateChanged) { | |
| 338 | for (const animation of stateChangeAnimationsRef.current) animation.cancel(); | |
| 339 | stateChangeAnimationsRef.current = []; | |
| 340 | } | |
| 341 | clearTransitionStyles(); | |
| 342 | ||
| 343 | const nextRect = element.getBoundingClientRect(); | |
| 344 | const nextHeight = nextRect.height; | |
| 345 | const nextPromptRect = prompt?.getBoundingClientRect() ?? null; | |
| 346 | const nextPromptTop = nextPromptRect?.top ?? null; | |
| 347 | const nextActionTop = action?.getBoundingClientRect().top ?? null; | |
| 348 | const previousHeight = interruptedHeight ?? previousHeightRef.current; | |
| 349 | const targetChanged = | |
| 350 | interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5; | |
| 351 | const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; | |
| 352 | const shouldAnimate = shouldAnimateComposerRestingTransition({ | |
| 353 | hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current, | |
| 354 | stateChanged, | |
| 355 | hasInterruptedAnimation: interruptedHeight !== null, | |
| 356 | }); | |
| 357 | ||
| 358 | if ( | |
| 359 | shouldAnimate && | |
| 360 | !prefersReducedMotion && | |
| 361 | previousHeight !== null && | |
| 362 | Math.abs(previousHeight - nextHeight) >= 0.5 | |
| 363 | ) { | |
| 364 | const remainingDuration = | |
| 365 | typeof interruptedDuration === "number" && interruptedCurrentTime !== null | |
| 366 | ? Math.max(1, interruptedDuration - interruptedCurrentTime) | |
| 367 | : COMPOSER_RESTING_TRANSITION_DURATION_MS; | |
| 368 | const duration = | |
| 369 | interruptedHeight !== null && !targetChanged | |
| 370 | ? remainingDuration | |
| 371 | : COMPOSER_RESTING_TRANSITION_DURATION_MS; | |
| 372 | element.style.overflow = "clip"; | |
| 373 | surface.style.height = "100%"; | |
| 374 | ||
| 375 | // The chat view resize-observes the overlay to place the timeline | |
| 376 | // inset, the scroll-to-end pill, and the mini player. Pinning the | |
| 377 | // overlay at the destination height turns that feedback into one | |
| 378 | // update instead of a ChatView re-render on every animation frame; | |
| 379 | // bottom alignment keeps the animating surface glued to the overlay's | |
| 380 | // stable bottom edge. The pin lasts only for the tween so later | |
| 381 | // attachment, thread, font, and viewport changes remain natural. | |
| 382 | const overlay = element.closest<HTMLElement>('[data-chat-composer-overlay="true"]'); | |
| 383 | let pinnedOverlayHeight: number | null = null; | |
| 384 | if (overlay) { | |
| 385 | pinnedOverlayHeight = overlay.getBoundingClientRect().height; | |
| 386 | overlay.style.height = `${String(pinnedOverlayHeight)}px`; | |
| 387 | overlay.style.display = "flex"; | |
| 388 | overlay.style.flexDirection = "column"; | |
| 389 | overlay.style.justifyContent = "flex-end"; | |
| 390 | pinnedOverlayRef.current = overlay; | |
| 391 | } | |
| 392 | ||
| 393 | // Keep the footer attached to the stable bottom edge while the outer | |
| 394 | // height changes. Its resting absolute layout otherwise spans the old | |
| 395 | // height on collapse, while its expanded flow layout falls below the | |
| 396 | // clipped surface on expansion. | |
| 397 | if (footer) { | |
| 398 | footer.style.position = "absolute"; | |
| 399 | footer.style.top = "auto"; | |
| 400 | footer.style.bottom = "1px"; | |
| 401 | footer.style.height = "3rem"; | |
| 402 | if (nextIsCollapsed) { | |
| 403 | footer.style.left = "auto"; | |
| 404 | footer.style.right = "1px"; | |
| 405 | } else { | |
| 406 | footer.style.left = "1px"; | |
| 407 | footer.style.right = "1px"; | |
| 408 | } | |
| 409 | } | |
| 410 | ||
| 411 | const animation = element.animate( | |
| 412 | [{ height: `${previousHeight}px` }, { height: `${nextHeight}px` }], | |
| 413 | { | |
| 414 | duration, | |
| 415 | easing: COMPOSER_RESTING_TRANSITION_EASING, | |
| 416 | }, | |
| 417 | ); | |
| 418 | animationRef.current = animation; | |
| 419 | animationTargetHeightRef.current = nextHeight; | |
| 420 | // Publish the destination overlay geometry in the same layout pass; | |
| 421 | // ResizeObserver remains the fallback for non-transition changes. | |
| 422 | if (pinnedOverlayHeight !== null) { | |
| 423 | onOverlayHeightChange(pinnedOverlayHeight); | |
| 424 | } | |
| 425 | ||
| 426 | const animatedRect = element.getBoundingClientRect(); | |
| 427 | const previousPromptTop = | |
| 428 | interruptedPromptTop ?? | |
| 429 | (previousContentOffsetsRef.current.promptFromTop === null | |
| 430 | ? null | |
| 431 | : animatedRect.top + previousContentOffsetsRef.current.promptFromTop); | |
| 432 | const previousActionTop = | |
| 433 | interruptedActionTop ?? | |
| 434 | (previousContentOffsetsRef.current.actionFromBottom === null | |
| 435 | ? null | |
| 436 | : animatedRect.bottom - previousContentOffsetsRef.current.actionFromBottom); | |
| 437 | const contentAnimations: Animation[] = []; | |
| 438 | const animateContentPosition = ( | |
| 439 | content: HTMLElement | null, | |
| 440 | previousTop: number | null, | |
| 441 | ) => { | |
| 442 | if (!content || previousTop === null) return; | |
| 443 | const offset = previousTop - content.getBoundingClientRect().top; | |
| 444 | if (Math.abs(offset) < 0.5) return; | |
| 445 | contentAnimations.push( | |
| 446 | content.animate( | |
| 447 | [{ transform: `translateY(${String(offset)}px)` }, { transform: "none" }], | |
| 448 | { | |
| 449 | duration, | |
| 450 | easing: COMPOSER_RESTING_TRANSITION_EASING, | |
| 451 | }, | |
| 452 | ), | |
| 453 | ); | |
| 454 | }; | |
| 455 | animateContentPosition(prompt, previousPromptTop); | |
| 456 | animateContentPosition(action, previousActionTop); | |
| 457 | contentAnimationsRef.current = contentAnimations; | |
| 458 | ||
| 459 | if (stateChanged) { | |
| 460 | const stateChangeAnimations: Animation[] = []; | |
| 461 | ||
| 462 | // A prompt that gains lines on expansion would otherwise slide up | |
| 463 | // from under the footer band as one block. Opening a bottom clip in | |
| 464 | // step with the tween instead unfurls the extra lines beneath the | |
| 465 | // rising first line, so no text crosses the returning controls. | |
| 466 | const previousPromptHeight = previousContentOffsetsRef.current.promptHeight; | |
| 467 | if ( | |
| 468 | !nextIsCollapsed && | |
| 469 | prompt && | |
| 470 | nextPromptRect && | |
| 471 | previousPromptHeight !== null && | |
| 472 | nextPromptRect.height - previousPromptHeight >= 0.5 | |
| 473 | ) { | |
| 474 | const hiddenHeight = nextPromptRect.height - previousPromptHeight; | |
| 475 | stateChangeAnimations.push( | |
| 476 | prompt.animate( | |
| 477 | [ | |
| 478 | { clipPath: `inset(0 0 ${String(hiddenHeight)}px 0)` }, | |
| 479 | { clipPath: "inset(0 0 0 0)" }, | |
| 480 | ], | |
| 481 | { | |
| 482 | duration, | |
| 483 | easing: COMPOSER_RESTING_TRANSITION_EASING, | |
| 484 | }, | |
| 485 | ), | |
| 486 | ); | |
| 487 | } | |
| 488 | ||
| 489 | // The footer controls teleport between the composer footer and the | |
| 490 | // context strip below it in a single commit. Fading the arriving | |
| 491 | // cluster in along its direction of travel reads as one continuous | |
| 492 | // move instead of a pop. Collapsing controls land in empty strip | |
| 493 | // space and can appear immediately, but expanding controls return | |
| 494 | // to the bottom row the prompt still occupies while the surface is | |
| 495 | // short, so they stay hidden through the first half of the tween | |
| 496 | // and fade in once the geometry has mostly settled. | |
| 497 | const arrivingControls = nextIsCollapsed | |
| 498 | ? restingControlsRef.current | |
| 499 | : element.querySelector<HTMLElement>('[data-chat-composer-controls="left"]'); | |
| 500 | if (arrivingControls) { | |
| 501 | const drift = nextIsCollapsed | |
| 502 | ? -COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX | |
| 503 | : COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX; | |
| 504 | stateChangeAnimations.push( | |
| 505 | arrivingControls.animate( | |
| 506 | [ | |
| 507 | { opacity: 0, transform: `translateY(${String(drift)}px)` }, | |
| 508 | { opacity: 1, transform: "none" }, | |
| 509 | ], | |
| 510 | { | |
| 511 | duration: nextIsCollapsed ? duration : duration / 2, | |
| 512 | delay: nextIsCollapsed ? 0 : duration / 2, | |
| 513 | fill: "backwards", | |
| 514 | easing: COMPOSER_RESTING_TRANSITION_EASING, | |
| 515 | }, | |
| 516 | ), | |
| 517 | ); | |
| 518 | } | |
| 519 | ||
| 520 | const arrivingImagePreviews = nextIsCollapsed | |
| 521 | ? Array.from( | |
| 522 | element.querySelectorAll<HTMLElement>('[data-chat-composer-resting-images="true"]'), | |
| 523 | ) | |
| 524 | : Array.from( | |
| 525 | element.querySelectorAll<HTMLElement>('[data-chat-composer-expanded-image="true"]'), | |
| 526 | ); | |
| 527 | for (const imagePreview of arrivingImagePreviews) { | |
| 528 | stateChangeAnimations.push( | |
| 529 | imagePreview.animate([{ opacity: 0 }, { opacity: 1 }], { | |
| 530 | duration: nextIsCollapsed ? duration : duration / 2, | |
| 531 | delay: nextIsCollapsed ? 0 : duration / 2, | |
| 532 | fill: "backwards", | |
| 533 | easing: COMPOSER_RESTING_TRANSITION_EASING, | |
| 534 | }), | |
| 535 | ); | |
| 536 | } | |
| 537 | stateChangeAnimationsRef.current = stateChangeAnimations; | |
| 538 | } | |
| 539 | ||
| 540 | const finishTransition = (cancelAnimations: boolean) => { | |
| 541 | if (animationRef.current !== animation) return; | |
| 542 | if (transitionCleanupTimeoutRef.current !== null) { | |
| 543 | window.clearTimeout(transitionCleanupTimeoutRef.current); | |
| 544 | transitionCleanupTimeoutRef.current = null; | |
| 545 | } | |
| 546 | if (cancelAnimations) { | |
| 547 | animation.cancel(); | |
| 548 | for (const contentAnimation of contentAnimationsRef.current) { | |
| 549 | contentAnimation.cancel(); | |
| 550 | } | |
| 551 | for (const stateChangeAnimation of stateChangeAnimationsRef.current) { | |
| 552 | stateChangeAnimation.cancel(); | |
| 553 | } | |
| 554 | } | |
| 555 | animationRef.current = null; | |
| 556 | animationTargetHeightRef.current = null; | |
| 557 | contentAnimationsRef.current = []; | |
| 558 | stateChangeAnimationsRef.current = []; | |
| 559 | clearTransitionStyles(); | |
| 560 | }; | |
| 561 | void animation.finished.catch(() => undefined).then(() => finishTransition(false)); | |
| 562 | // A suspended document timeline can leave `finished` pending while | |
| 563 | // these measurement styles remain active. Wall-clock cleanup makes | |
| 564 | // the natural layout the eventual source of truth in that case. | |
| 565 | transitionCleanupTimeoutRef.current = window.setTimeout( | |
| 566 | () => finishTransition(true), | |
| 567 | duration + COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS, | |
| 568 | ); | |
| 569 | } else { | |
| 570 | animationTargetHeightRef.current = null; | |
| 571 | } | |
| 572 | ||
| 573 | previousCollapsedRef.current = nextIsCollapsed; | |
| 574 | previousHeightRef.current = nextHeight; | |
| 575 | previousContentOffsetsRef.current = { | |
| 576 | promptFromTop: nextPromptTop === null ? null : nextPromptTop - nextRect.top, | |
| 577 | promptHeight: nextPromptRect?.height ?? null, | |
| 578 | actionFromBottom: nextActionTop === null ? null : nextRect.bottom - nextActionTop, | |
| 579 | }; | |
| 580 | }, | |
| 581 | [clearTransitionStyles, onOverlayHeightChange, restingControlsRef], | |
| 582 | ); | |
| 583 | ||
| 584 | useLayoutEffect(() => { | |
| 585 | const requestId = transitionLayoutRequestRef.current + 1; | |
| 586 | transitionLayoutRequestRef.current = requestId; | |
| 587 | const stateChanged = previousCollapsedRef.current !== isCollapsed; | |
| 588 | // A non-Git context strip enters or leaves flow through ChatView state in | |
| 589 | // an earlier layout effect. Let React flush that parent update before the | |
| 590 | // FLIP reads its destination geometry, while still running before paint. | |
| 591 | queueMicrotask(() => { | |
| 592 | if (transitionLayoutRequestRef.current !== requestId) return; | |
| 593 | transitionToCurrentGeometry(stateChanged); | |
| 594 | }); | |
| 595 | return () => { | |
| 596 | if (transitionLayoutRequestRef.current === requestId) { | |
| 597 | transitionLayoutRequestRef.current += 1; | |
| 598 | } | |
| 599 | }; | |
| 600 | }, [isCollapsed, transitionToCurrentGeometry]); | |
| 601 | ||
| 602 | useLayoutEffect(() => { | |
| 603 | const element = elementRef.current; | |
| 604 | if (!element || typeof ResizeObserver === "undefined") return; | |
| 605 | ||
| 606 | const body = element.querySelector<HTMLElement>('[data-chat-composer-body="true"]'); | |
| 607 | const observer = new ResizeObserver((entries) => { | |
| 608 | if (animationRef.current) { | |
| 609 | if (body && entries.some((entry) => entry.target === body)) { | |
| 610 | transitionToCurrentGeometry(false); | |
| 611 | } | |
| 612 | return; | |
| 613 | } | |
| 614 | const elementRect = element.getBoundingClientRect(); | |
| 615 | const visibleTransitionElement = (selector: string) => | |
| 616 | Array.from(element.querySelectorAll<HTMLElement>(selector)).find( | |
| 617 | (candidate) => candidate.getClientRects().length > 0, | |
| 618 | ) ?? null; | |
| 619 | const promptRect = visibleTransitionElement( | |
| 620 | '[data-testid="composer-editor"], [data-chat-composer-transition-prompt="true"]', | |
| 621 | )?.getBoundingClientRect(); | |
| 622 | const actionTop = visibleTransitionElement( | |
| 623 | '[data-chat-composer-transition-actions="true"]', | |
| 624 | )?.getBoundingClientRect().top; | |
| 625 | previousHeightRef.current = elementRect.height; | |
| 626 | previousContentOffsetsRef.current = { | |
| 627 | promptFromTop: promptRect === undefined ? null : promptRect.top - elementRect.top, | |
| 628 | promptHeight: promptRect?.height ?? null, | |
| 629 | actionFromBottom: actionTop === undefined ? null : elementRect.bottom - actionTop, | |
| 630 | }; | |
| 631 | }); | |
| 632 | observer.observe(element); | |
| 633 | if (body) observer.observe(body); | |
| 634 | return () => observer.disconnect(); | |
| 635 | }, [transitionToCurrentGeometry]); | |
| 636 | ||
| 637 | useEffect(() => { | |
| 638 | // Host discovery and width measurement settle through layout updates on | |
| 639 | // mount. Treat that bootstrap as initial geometry so an existing thread | |
| 640 | // paints at rest instead of visibly collapsing from the expanded height. | |
| 641 | hasCompletedInitialLayoutRef.current = true; | |
| 642 | return () => { | |
| 643 | if (transitionCleanupTimeoutRef.current !== null) { | |
| 644 | window.clearTimeout(transitionCleanupTimeoutRef.current); | |
| 645 | transitionCleanupTimeoutRef.current = null; | |
| 646 | } | |
| 647 | animationRef.current?.cancel(); | |
| 648 | animationRef.current = null; | |
| 649 | animationTargetHeightRef.current = null; | |
| 650 | for (const animation of contentAnimationsRef.current) animation.cancel(); | |
| 651 | contentAnimationsRef.current = []; | |
| 652 | for (const animation of stateChangeAnimationsRef.current) animation.cancel(); | |
| 653 | stateChangeAnimationsRef.current = []; | |
| 654 | clearTransitionStyles(); | |
| 655 | }; | |
| 656 | }, [clearTransitionStyles]); | |
| 657 | ||
| 658 | return elementRef; | |
| 659 | } | |
| 660 | ||
| 212 | 661 | function composerCommandMenuPositionsEqual( |
| 213 | 662 | a: ComposerCommandMenuPosition, |
| 214 | 663 | b: ComposerCommandMenuPosition, |
| ... | ... | @@ -375,15 +824,6 @@ const runtimeModeConfig: Record< |
| 375 | 824 | }; |
| 376 | 825 | |
| 377 | 826 | const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; |
| 378 | const COMPOSER_FLOATING_LAYER_SELECTOR = [ | |
| 379 | '[data-composer-drawer-layer="true"]', | |
| 380 | '[data-slot="popover-popup"]', | |
| 381 | '[data-slot="menu-popup"]', | |
| 382 | '[data-slot="select-popup"]', | |
| 383 | '[data-slot="combobox-popup"]', | |
| 384 | '[data-slot="autocomplete-popup"]', | |
| 385 | ].join(","); | |
| 386 | ||
| 387 | 827 | const extendReplacementRangeForTrailingSpace = ( |
| 388 | 828 | text: string, |
| 389 | 829 | rangeEnd: number, |
| ... | ... | @@ -412,17 +852,122 @@ const terminalContextIdListsEqual = ( |
| 412 | 852 | ): boolean => |
| 413 | 853 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); |
| 414 | 854 | |
| 415 | function isInsideComposerFloatingLayer(element: Element): boolean { | |
| 416 | return element.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; | |
| 855 | function elementOuterWidth(element: HTMLElement): number { | |
| 856 | const width = element.getBoundingClientRect().width; | |
| 857 | if (width === 0) return 0; | |
| 858 | const style = getComputedStyle(element); | |
| 859 | return ( | |
| 860 | width + | |
| 861 | (Number.parseFloat(style.marginInlineStart) || 0) + | |
| 862 | (Number.parseFloat(style.marginInlineEnd) || 0) | |
| 863 | ); | |
| 864 | } | |
| 865 | ||
| 866 | function elementInlineMarginWidth(element: HTMLElement): number { | |
| 867 | const style = getComputedStyle(element); | |
| 868 | return ( | |
| 869 | (Number.parseFloat(style.marginInlineStart) || 0) + | |
| 870 | (Number.parseFloat(style.marginInlineEnd) || 0) | |
| 871 | ); | |
| 872 | } | |
| 873 | ||
| 874 | function providerModelPickerNaturalWidth(picker: HTMLElement): number { | |
| 875 | const renderedWidth = picker.getBoundingClientRect().width; | |
| 876 | if (renderedWidth === 0) return 0; | |
| 877 | const style = getComputedStyle(picker); | |
| 878 | const label = picker.querySelector<HTMLElement>('[data-chat-provider-model-picker-label="true"]'); | |
| 879 | const hiddenLabelWidth = label ? Math.max(0, label.scrollWidth - label.clientWidth) : 0; | |
| 880 | const maxWidth = Number.parseFloat(style.maxWidth); | |
| 881 | const naturalWidth = Math.min( | |
| 882 | renderedWidth + hiddenLabelWidth, | |
| 883 | Number.isFinite(maxWidth) ? maxWidth : Number.POSITIVE_INFINITY, | |
| 884 | ); | |
| 885 | return naturalWidth + elementInlineMarginWidth(picker); | |
| 886 | } | |
| 887 | ||
| 888 | function providerModelPickerMinimumWidth(picker: HTMLElement): number { | |
| 889 | const minWidth = Number.parseFloat(getComputedStyle(picker).minWidth) || 0; | |
| 890 | return minWidth + elementInlineMarginWidth(picker); | |
| 891 | } | |
| 892 | ||
| 893 | function useRestingComposerControlsLayout(host: HTMLDivElement | null) { | |
| 894 | const controlsRef = useRef<HTMLDivElement>(null); | |
| 895 | const hostRef = useRef(host); | |
| 896 | hostRef.current = host; | |
| 897 | const [layout, setLayout] = useState({ hiddenCount: 0, visible: true }); | |
| 898 | ||
| 899 | const measure = useCallback(() => { | |
| 900 | const currentHost = hostRef.current; | |
| 901 | const controls = controlsRef.current; | |
| 902 | // The controls only mount while the composer rests, so the expanded | |
| 903 | // composer pays no layout reads here despite the every-render effect. | |
| 904 | if (currentHost === null || !controls) return; | |
| 905 | ||
| 906 | const blocks = Array.from(controls.querySelectorAll<HTMLElement>("[data-resting-block]")); | |
| 907 | ||
| 908 | // Hidden blocks and the unused overflow trigger stay mounted out of flow | |
| 909 | // at full size. The picker is the one flexible item: recover its intended | |
| 910 | // width from the truncated label, then let it contract only after all | |
| 911 | // trailing blocks have moved into overflow. | |
| 912 | const gap = Number.parseFloat(getComputedStyle(controls).columnGap) || 0; | |
| 913 | const picker = controls.querySelector<HTMLElement>("[data-chat-provider-model-picker]"); | |
| 914 | const leadingControl = | |
| 915 | picker ?? controls.querySelector<HTMLElement>('[data-chat-provider-unavailable="true"]'); | |
| 916 | if (!leadingControl) return; | |
| 917 | // Separators are display:none on phone widths; a hidden one takes no gap. | |
| 918 | const separator = controls.querySelector<HTMLElement>("[data-resting-controls-separator]"); | |
| 919 | const separatorWidth = separator ? elementOuterWidth(separator) : 0; | |
| 920 | const overflow = controls.querySelector<HTMLElement>("[data-resting-controls-overflow]"); | |
| 921 | const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0; | |
| 922 | const naturalFixedWidth = | |
| 923 | (picker ? providerModelPickerNaturalWidth(picker) : elementOuterWidth(leadingControl)) + | |
| 924 | separatorAndGapWidth; | |
| 925 | const minimumFixedWidth = | |
| 926 | (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) + | |
| 927 | separatorAndGapWidth; | |
| 928 | const blockWidths = blocks.map(elementOuterWidth); | |
| 929 | const overflowWidth = overflow ? elementOuterWidth(overflow) : 0; | |
| 930 | const hostWidth = currentHost.clientWidth; | |
| 931 | ||
| 932 | setLayout((current) => { | |
| 933 | const next = resolveRestingComposerControlsLayout({ | |
| 934 | hostWidth, | |
| 935 | gap, | |
| 936 | naturalFixedWidth, | |
| 937 | minimumFixedWidth, | |
| 938 | blockWidths, | |
| 939 | overflowWidth, | |
| 940 | }); | |
| 941 | return next.hiddenCount === current.hiddenCount && next.visible === current.visible | |
| 942 | ? current | |
| 943 | : next; | |
| 944 | }); | |
| 945 | }, []); | |
| 946 | ||
| 947 | useLayoutEffect(measure); | |
| 948 | useEffect(() => { | |
| 949 | if (!host) return; | |
| 950 | const observer = new ResizeObserver(measure); | |
| 951 | observer.observe(host); | |
| 952 | document.fonts.addEventListener("loadingdone", measure); | |
| 953 | return () => { | |
| 954 | observer.disconnect(); | |
| 955 | document.fonts.removeEventListener("loadingdone", measure); | |
| 956 | }; | |
| 957 | }, [host, measure]); | |
| 958 | ||
| 959 | return { controlsRef, hiddenBlockCount: layout.hiddenCount, controlsVisible: layout.visible }; | |
| 417 | 960 | } |
| 418 | 961 | |
| 419 | 962 | const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { |
| 420 | 963 | showInteractionModeToggle: boolean; |
| 421 | 964 | interactionMode: ProviderInteractionMode; |
| 422 | 965 | runtimeMode: RuntimeMode; |
| 966 | size?: "sm" | "xs"; | |
| 423 | 967 | onToggleInteractionMode: () => void; |
| 424 | 968 | onRuntimeModeChange: (mode: RuntimeMode) => void; |
| 425 | 969 | }) { |
| 970 | const size = props.size ?? "sm"; | |
| 426 | 971 | const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; |
| 427 | 972 | const RuntimeModeIcon = runtimeModeOption.icon; |
| 428 | 973 | const interactionModeTooltip = |
| ... | ... | @@ -432,16 +977,19 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 432 | 977 | |
| 433 | 978 | const interactionModeToggle = props.showInteractionModeToggle ? ( |
| 434 | 979 | <> |
| 435 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 980 | <ComposerControlSeparator size={size} /> | |
| 436 | 981 | <Tooltip> |
| 437 | 982 | <TooltipTrigger |
| 438 | 983 | render={ |
| 439 | 984 | <ComposerControl |
| 985 | size={size} | |
| 440 | 986 | className={cn( |
| 441 | 987 | "shrink-0 whitespace-nowrap", |
| 442 | 988 | props.interactionMode === "plan" |
| 443 | 989 | ? "bg-accent text-accent-foreground hover:bg-accent/80" |
| 444 | : "text-secondary-label hover:text-foreground", | |
| 990 | : size === "xs" | |
| 991 | ? undefined | |
| 992 | : "text-secondary-label hover:text-foreground", | |
| 445 | 993 | )} |
| 446 | 994 | type="button" |
| 447 | 995 | onClick={props.onToggleInteractionMode} |
| ... | ... | @@ -450,9 +998,17 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 450 | 998 | } |
| 451 | 999 | > |
| 452 | 1000 | {props.interactionMode === "plan" ? ( |
| 453 | <ComposerControlIcon icon={PencilRulerIcon} className="text-current opacity-100" /> | |
| 1001 | <ComposerControlIcon | |
| 1002 | icon={PencilRulerIcon} | |
| 1003 | size={size} | |
| 1004 | className="text-current opacity-100" | |
| 1005 | /> | |
| 454 | 1006 | ) : ( |
| 455 | <ComposerControlIcon icon={BotIcon} opticalSize="large" /> | |
| 1007 | <ComposerControlIcon | |
| 1008 | icon={BotIcon} | |
| 1009 | size={size} | |
| 1010 | opticalSize={size === "xs" ? "default" : "large"} | |
| 1011 | /> | |
| 456 | 1012 | )} |
| 457 | 1013 | <span className="sr-only sm:not-sr-only"> |
| 458 | 1014 | {props.interactionMode === "plan" ? "Plan" : "Build"} |
| ... | ... | @@ -465,7 +1021,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 465 | 1021 | |
| 466 | 1022 | return ( |
| 467 | 1023 | <> |
| 468 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 1024 | <ComposerControlSeparator size={size} /> | |
| 469 | 1025 | |
| 470 | 1026 | <Tooltip> |
| 471 | 1027 | <Select |
| ... | ... | @@ -473,12 +1029,18 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 473 | 1029 | onValueChange={(value) => props.onRuntimeModeChange(value!)} |
| 474 | 1030 | > |
| 475 | 1031 | <TooltipTrigger |
| 476 | render={<ComposerSelectControl className="font-medium" aria-label="Runtime mode" />} | |
| 1032 | render={ | |
| 1033 | <ComposerSelectControl | |
| 1034 | size={size} | |
| 1035 | className={size === "xs" ? undefined : "font-medium"} | |
| 1036 | aria-label="Runtime mode" | |
| 1037 | /> | |
| 1038 | } | |
| 477 | 1039 | > |
| 478 | <ComposerControlIcon icon={RuntimeModeIcon} /> | |
| 1040 | <ComposerControlIcon icon={RuntimeModeIcon} size={size} /> | |
| 479 | 1041 | <SelectValue>{runtimeModeOption.label}</SelectValue> |
| 480 | 1042 | </TooltipTrigger> |
| 481 | <SelectPopup alignItemWithTrigger={false}> | |
| 1043 | <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}> | |
| 482 | 1044 | {runtimeModeOptions.map((mode) => { |
| 483 | 1045 | const option = runtimeModeConfig[mode]; |
| 484 | 1046 | const OptionIcon = option.icon; |
| ... | ... | @@ -530,6 +1092,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 530 | 1092 | hasSendableContent: boolean; |
| 531 | 1093 | preserveComposerFocusOnPointerDown?: boolean; |
| 532 | 1094 | showSendWhileRunning?: boolean; |
| 1095 | showSecondaryStatus: boolean; | |
| 533 | 1096 | onPreviousPendingQuestion: () => void; |
| 534 | 1097 | onInterrupt: () => void; |
| 535 | 1098 | onImplementPlanInNewThread: () => void; |
| ... | ... | @@ -539,7 +1102,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 539 | 1102 | }) { |
| 540 | 1103 | return ( |
| 541 | 1104 | <> |
| 542 | {props.activeContextWindow ? ( | |
| 1105 | {props.showSecondaryStatus && props.activeContextWindow ? ( | |
| 543 | 1106 | <ContextWindowMeter |
| 544 | 1107 | usage={props.activeContextWindow} |
| 545 | 1108 | modelDisplayName={props.activeThreadModelDisplayName} |
| ... | ... | @@ -577,6 +1140,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 577 | 1140 | export interface ChatComposerHandle { |
| 578 | 1141 | focusAtEnd: () => void; |
| 579 | 1142 | focusAt: (cursor: number) => void; |
| 1143 | /** Undo only a scroll-triggered collapse when the timeline returns to its live edge. */ | |
| 1144 | restoreAfterTimelineReachedEnd: () => void; | |
| 580 | 1145 | addDroppedFiles: (files: File[]) => void; |
| 581 | 1146 | insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; |
| 582 | 1147 | citeAssistantText: ( |
| ... | ... | @@ -702,6 +1267,12 @@ export interface ChatComposerProps { |
| 702 | 1267 | keybindings: ResolvedKeybindingsConfig; |
| 703 | 1268 | terminalOpen: boolean; |
| 704 | 1269 | gitCwd: string | null; |
| 1270 | restingControlsHost: HTMLDivElement | null; | |
| 1271 | restingControlsHaveLeadingContext: boolean; | |
| 1272 | onRestingControlsVisibilityChange: (visible: boolean) => void; | |
| 1273 | getTimelineScrollableNode: () => HTMLElement | null; | |
| 1274 | isTimelineAtLogicalEnd: () => boolean; | |
| 1275 | onComposerOverlayHeightChange: (height: number) => void; | |
| 705 | 1276 | |
| 706 | 1277 | // Refs the parent needs kept in sync |
| 707 | 1278 | promptRef: React.RefObject<string>; |
| ... | ... | @@ -798,6 +1369,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 798 | 1369 | keybindings, |
| 799 | 1370 | terminalOpen, |
| 800 | 1371 | gitCwd, |
| 1372 | restingControlsHost, | |
| 1373 | restingControlsHaveLeadingContext, | |
| 1374 | onRestingControlsVisibilityChange, | |
| 1375 | getTimelineScrollableNode, | |
| 1376 | isTimelineAtLogicalEnd, | |
| 1377 | onComposerOverlayHeightChange, | |
| 801 | 1378 | promptRef, |
| 802 | 1379 | composerRef, |
| 803 | 1380 | composerImagesRef, |
| ... | ... | @@ -849,6 +1426,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 849 | 1426 | const composerElementContexts = composerDraft.elementContexts; |
| 850 | 1427 | const composerPreviewAnnotations = composerDraft.previewAnnotations; |
| 851 | 1428 | const composerReviewComments = composerDraft.reviewComments; |
| 1429 | const standaloneComposerImages = useMemo(() => { | |
| 1430 | const previewAnnotationIds = new Set( | |
| 1431 | composerPreviewAnnotations.map((annotation) => annotation.id), | |
| 1432 | ); | |
| 1433 | return composerImages.filter((image) => !previewAnnotationIds.has(image.id)); | |
| 1434 | }, [composerImages, composerPreviewAnnotations]); | |
| 852 | 1435 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; |
| 853 | 1436 | const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId); |
| 854 | 1437 | const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length; |
| ... | ... | @@ -1288,6 +1871,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1288 | 1871 | const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); |
| 1289 | 1872 | const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); |
| 1290 | 1873 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| 1874 | const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false); | |
| 1291 | 1875 | const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null); |
| 1292 | 1876 | const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>( |
| 1293 | 1877 | null, |
| ... | ... | @@ -1322,6 +1906,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1322 | 1906 | const mobileComposerExpandFrameRef = useRef<number | null>(null); |
| 1323 | 1907 | const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null); |
| 1324 | 1908 | const mobileComposerExpandInFlightRef = useRef(false); |
| 1909 | const desktopOutsidePointerInFlightRef = useRef(false); | |
| 1910 | const desktopOutsidePointerReleaseTimeoutRef = useRef<number | null>(null); | |
| 1911 | const composerScrollCollapseTimeoutRef = useRef<number | null>(null); | |
| 1912 | const composerScrollCollapseEligibleRef = useRef(false); | |
| 1913 | const windowRefocusInFlightRef = useRef(false); | |
| 1914 | const composerScrollGestureRef = useRef(createComposerScrollGestureState()); | |
| 1325 | 1915 | const stashPulseKeyRef = useRef(0); |
| 1326 | 1916 | const stashPulseTimeoutRef = useRef<number | null>(null); |
| 1327 | 1917 | /** |
| ... | ... | @@ -1509,7 +2099,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1509 | 2099 | (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null); |
| 1510 | 2100 | const showCollapsedMobilePromptRow = |
| 1511 | 2101 | isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; |
| 1512 | ||
| 2102 | const showComposerAttachAction = fileStagingLimit !== null && pendingUserInputs.length === 0; | |
| 1513 | 2103 | const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; |
| 1514 | 2104 | const composerFooterActionLayoutKey = useMemo(() => { |
| 1515 | 2105 | if (activePendingProgress) { |
| ... | ... | @@ -1576,7 +2166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1576 | 2166 | onPromptChange: setPromptFromTraits, |
| 1577 | 2167 | planModeEnabled: settings.planModeEnabled, |
| 1578 | 2168 | }); |
| 1579 | const providerTraitsPicker = renderProviderTraitsPicker({ | |
| 2169 | const providerTraitsPickerInput = { | |
| 1580 | 2170 | provider: selectedProvider, |
| 1581 | 2171 | instanceId: selectedInstanceId, |
| 1582 | 2172 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), |
| ... | ... | @@ -1587,7 +2177,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1587 | 2177 | prompt, |
| 1588 | 2178 | onPromptChange: setPromptFromTraits, |
| 1589 | 2179 | planModeEnabled: settings.planModeEnabled, |
| 2180 | isComposerOwned: true, | |
| 2181 | } satisfies Parameters<typeof renderProviderTraitsPicker>[0]; | |
| 2182 | const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput); | |
| 2183 | const restingProviderTraitsPicker = renderProviderTraitsPicker({ | |
| 2184 | ...providerTraitsPickerInput, | |
| 2185 | size: "xs", | |
| 1590 | 2186 | }); |
| 2187 | const { | |
| 2188 | controlsRef: restingComposerControlsRef, | |
| 2189 | hiddenBlockCount: restingControlsHiddenBlockCount, | |
| 2190 | controlsVisible: restingControlsVisible, | |
| 2191 | } = useRestingComposerControlsLayout(restingControlsHost); | |
| 1591 | 2192 | const pendingPrimaryAction = useMemo( |
| 1592 | 2193 | () => |
| 1593 | 2194 | activePendingProgress |
| ... | ... | @@ -1820,6 +2421,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1820 | 2421 | setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); |
| 1821 | 2422 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); |
| 1822 | 2423 | setIsDragOverComposer(false); |
| 2424 | setIsComposerScrollCollapsed(false); | |
| 1823 | 2425 | }, [draftId, activeThreadId, promptRef]); |
| 1824 | 2426 | |
| 1825 | 2427 | // ------------------------------------------------------------------ |
| ... | ... | @@ -1963,6 +2565,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1963 | 2565 | // ------------------------------------------------------------------ |
| 1964 | 2566 | // Callbacks: prompt change |
| 1965 | 2567 | // ------------------------------------------------------------------ |
| 2568 | const expandComposerForEditorChange = useCallback(() => { | |
| 2569 | // Editor changes win over the momentum tail of the active scroll gesture. | |
| 2570 | suppressActiveComposerScrollGesture( | |
| 2571 | composerScrollGestureRef.current, | |
| 2572 | window.performance.now(), | |
| 2573 | COMPOSER_SCROLL_GESTURE_RESET_MS, | |
| 2574 | ); | |
| 2575 | setIsComposerScrollCollapsed(false); | |
| 2576 | }, []); | |
| 2577 | ||
| 1966 | 2578 | const onPromptChange = useCallback( |
| 1967 | 2579 | ( |
| 1968 | 2580 | nextPrompt: string, |
| ... | ... | @@ -1971,6 +2583,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1971 | 2583 | cursorAdjacentToMention: boolean, |
| 1972 | 2584 | terminalContextIds: string[], |
| 1973 | 2585 | ) => { |
| 2586 | expandComposerForEditorChange(); | |
| 1974 | 2587 | if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { |
| 1975 | 2588 | setComposerCursor(nextCursor); |
| 1976 | 2589 | setComposerTrigger( |
| ... | ... | @@ -2000,6 +2613,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2000 | 2613 | }, |
| 2001 | 2614 | [ |
| 2002 | 2615 | activePendingProgress?.activeQuestion, |
| 2616 | expandComposerForEditorChange, | |
| 2003 | 2617 | pendingUserInputs.length, |
| 2004 | 2618 | onChangeActivePendingUserInputCustomAnswer, |
| 2005 | 2619 | promptRef, |
| ... | ... | @@ -2061,6 +2675,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2061 | 2675 | setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); |
| 2062 | 2676 | if (options?.focusEditorAfterReplace !== false) { |
| 2063 | 2677 | window.requestAnimationFrame(() => { |
| 2678 | // Type-to-focus routes only the first key through here; once the | |
| 2679 | // controlled update focuses the editor, later keys land natively. | |
| 2680 | // Skip the deferred caret placement when the draft has moved on, | |
| 2681 | // or it drags the caret back behind what was typed since. | |
| 2682 | if (promptRef.current !== next.text) return; | |
| 2064 | 2683 | composerEditorRef.current?.focusAt(nextCursor); |
| 2065 | 2684 | }); |
| 2066 | 2685 | } |
| ... | ... | @@ -2644,7 +3263,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2644 | 3263 | // unique image into the overflow list for nothing. |
| 2645 | 3264 | const existingDedupKeys = new Set( |
| 2646 | 3265 | composerImagesRef.current.map( |
| 2647 | (image) => `${image.mimeType} ${image.sizeBytes} ${image.name}`, | |
| 3266 | (image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`, | |
| 2648 | 3267 | ), |
| 2649 | 3268 | ); |
| 2650 | 3269 | const capacity = Math.max( |
| ... | ... | @@ -2658,7 +3277,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2658 | 3277 | (attachment) => |
| 2659 | 3278 | !existingIds.has(attachment.id) && |
| 2660 | 3279 | !existingDedupKeys.has( |
| 2661 | `${attachment.mimeType} ${attachment.sizeBytes} ${attachment.name}`, | |
| 3280 | `${attachment.mimeType}\0${attachment.sizeBytes}\0${attachment.name}`, | |
| 2662 | 3281 | ), |
| 2663 | 3282 | ); |
| 2664 | 3283 | // Anything past the attachment limit cannot be restored. The entry is |
| ... | ... | @@ -2802,10 +3421,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2802 | 3421 | // the composer has been cleared the user can type something genuinely |
| 2803 | 3422 | // new (or switch threads) while encoding continues, and that deserves its |
| 2804 | 3423 | // own entry. |
| 2805 | const snapshotKey = `${String(composerDraftTarget)} ${prompt} ${images | |
| 3424 | const attachmentKey = images | |
| 2806 | 3425 | .map((image) => `image:${image.id}`) |
| 2807 | 3426 | .concat(files.map((file) => `file:${file.id}`)) |
| 2808 | .join(",")}`; | |
| 3427 | .join(","); | |
| 3428 | const snapshotKey = [String(composerDraftTarget), prompt, attachmentKey].join("\n"); | |
| 2809 | 3429 | if (stashInFlightRef.current.has(snapshotKey)) return; |
| 2810 | 3430 | stashInFlightRef.current.add(snapshotKey); |
| 2811 | 3431 | |
| ... | ... | @@ -2986,6 +3606,365 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2986 | 3606 | steps={activeTaskSteps} |
| 2987 | 3607 | /> |
| 2988 | 3608 | ) : null; |
| 3609 | const hasImageAttachmentAttention = standaloneComposerImages.some((image) => { | |
| 3610 | const upload = uploadsByImageId[image.id]; | |
| 3611 | const failedInCurrentEnvironment = | |
| 3612 | supportsAttachmentUploads && | |
| 3613 | upload?.status === "failed" && | |
| 3614 | upload.environmentId === environmentId; | |
| 3615 | // A failed upload remains actionable in the expanded attachment tray, but | |
| 3616 | // its collapsed thumbnail is enough to signal that the draft has images. | |
| 3617 | return nonPersistedComposerImageIdSet.has(image.id) && !failedInCurrentEnvironment; | |
| 3618 | }); | |
| 3619 | // Banners and the tasks badge dock above the surface rather than inside | |
| 3620 | // it, so they do not hold the composer open; only surface-internal chrome | |
| 3621 | // does. | |
| 3622 | const composerHasExpandedChrome = | |
| 3623 | showComposerTopDrawer || | |
| 3624 | isTasksDrawerOpen || | |
| 3625 | composerMenuOpen || | |
| 3626 | isStashMenuOpen || | |
| 3627 | isDragOverComposer || | |
| 3628 | isPreparingWorktree || | |
| 3629 | noProviderAvailable || | |
| 3630 | projectSelectionRequired || | |
| 3631 | environmentUnavailable !== null || | |
| 3632 | composerSubmissionError !== null || | |
| 3633 | providerInputSubmissionError !== null || | |
| 3634 | hasImageAttachmentAttention; | |
| 3635 | const isComposerResting = shouldUseRestingComposerLayout({ | |
| 3636 | isExistingThread: routeKind === "server" && activeThreadId !== null, | |
| 3637 | isMobileViewport, | |
| 3638 | isFocused: isComposerFocused && !isComposerScrollCollapsed, | |
| 3639 | hasExpandedChrome: composerHasExpandedChrome, | |
| 3640 | }); | |
| 3641 | // The relocated controls live in the context strip whenever the composer is | |
| 3642 | // collapsed for any reason, the desktop resting layout or the phone | |
| 3643 | // collapse. Both leave the footer unrendered, so the strip is the only place | |
| 3644 | // to see or change the model without expanding the composer. | |
| 3645 | const composerControlsInStrip = isComposerResting || isComposerCollapsedMobile; | |
| 3646 | const composerControlsVisibleInStrip = composerControlsInStrip && restingControlsVisible; | |
| 3647 | useLayoutEffect(() => { | |
| 3648 | onRestingControlsVisibilityChange(composerControlsVisibleInStrip); | |
| 3649 | }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]); | |
| 3650 | const restingImagePreviewCounts = getRestingComposerImagePreviewCounts( | |
| 3651 | standaloneComposerImages.length, | |
| 3652 | ); | |
| 3653 | const restingComposerImages = standaloneComposerImages.slice( | |
| 3654 | 0, | |
| 3655 | restingImagePreviewCounts.visibleCount, | |
| 3656 | ); | |
| 3657 | const collapsedComposerImagePreviews = | |
| 3658 | restingComposerImages.length > 0 ? ( | |
| 3659 | <div | |
| 3660 | data-chat-composer-resting-images="true" | |
| 3661 | className="flex shrink-0 items-center gap-1 ps-1" | |
| 3662 | > | |
| 3663 | {restingComposerImages.map((image) => ( | |
| 3664 | <button | |
| 3665 | key={image.id} | |
| 3666 | type="button" | |
| 3667 | className="relative size-7 shrink-0 cursor-zoom-in overflow-hidden rounded-md border border-border/70 bg-muted/60" | |
| 3668 | aria-label={`Preview ${image.name}`} | |
| 3669 | onPointerDown={(event) => event.preventDefault()} | |
| 3670 | onClick={() => { | |
| 3671 | const preview = buildExpandedImagePreview(composerImages, image.id); | |
| 3672 | if (preview) onExpandImage(preview); | |
| 3673 | }} | |
| 3674 | > | |
| 3675 | {image.previewUrl ? ( | |
| 3676 | <img src={image.previewUrl} alt="" className="size-full object-cover" /> | |
| 3677 | ) : ( | |
| 3678 | <FileIcon className="m-auto size-3.5 text-secondary-label" /> | |
| 3679 | )} | |
| 3680 | </button> | |
| 3681 | ))} | |
| 3682 | {restingImagePreviewCounts.overflowCount > 0 ? ( | |
| 3683 | <button | |
| 3684 | type="button" | |
| 3685 | className="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md border border-border/70 bg-muted/60 font-medium text-secondary-label text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" | |
| 3686 | aria-label={`Show ${String(restingImagePreviewCounts.overflowCount)} more image attachments`} | |
| 3687 | onPointerDown={(event) => event.preventDefault()} | |
| 3688 | onClick={() => { | |
| 3689 | if (isComposerCollapsedMobile) { | |
| 3690 | expandMobileComposer(); | |
| 3691 | return; | |
| 3692 | } | |
| 3693 | setIsComposerScrollCollapsed(false); | |
| 3694 | setIsComposerFocused(true); | |
| 3695 | scheduleComposerFocus(); | |
| 3696 | }} | |
| 3697 | > | |
| 3698 | +{restingImagePreviewCounts.overflowCount} | |
| 3699 | </button> | |
| 3700 | ) : null} | |
| 3701 | </div> | |
| 3702 | ) : null; | |
| 3703 | const composerMainSurfaceRef = useComposerRestingTransition( | |
| 3704 | composerControlsInStrip, | |
| 3705 | restingComposerControlsRef, | |
| 3706 | onComposerOverlayHeightChange, | |
| 3707 | ); | |
| 3708 | const canTrackComposerScrollGesture = | |
| 3709 | routeKind === "server" && activeThreadId !== null && !isMobileViewport; | |
| 3710 | const canScrollCollapseComposer = | |
| 3711 | canTrackComposerScrollGesture && !composerHasExpandedChrome && !showInlineTasksBadge; | |
| 3712 | composerScrollCollapseEligibleRef.current = canScrollCollapseComposer; | |
| 3713 | ||
| 3714 | useEffect(() => { | |
| 3715 | if (!canScrollCollapseComposer) { | |
| 3716 | setIsComposerScrollCollapsed(false); | |
| 3717 | } | |
| 3718 | }, [canScrollCollapseComposer]); | |
| 3719 | ||
| 3720 | // Returning to the window re-fires focus on the element that already held | |
| 3721 | // it. That focus arrives after the window's own event, so a window focus | |
| 3722 | // marks the frame in which it should be ignored by the form's capture. | |
| 3723 | useEffect(() => { | |
| 3724 | if (!isComposerScrollCollapsed) return; | |
| 3725 | let frame: number | null = null; | |
| 3726 | const onWindowFocus = () => { | |
| 3727 | windowRefocusInFlightRef.current = true; | |
| 3728 | if (frame !== null) window.cancelAnimationFrame(frame); | |
| 3729 | frame = window.requestAnimationFrame(() => { | |
| 3730 | frame = null; | |
| 3731 | windowRefocusInFlightRef.current = false; | |
| 3732 | }); | |
| 3733 | }; | |
| 3734 | window.addEventListener("focus", onWindowFocus); | |
| 3735 | return () => { | |
| 3736 | window.removeEventListener("focus", onWindowFocus); | |
| 3737 | if (frame !== null) window.cancelAnimationFrame(frame); | |
| 3738 | windowRefocusInFlightRef.current = false; | |
| 3739 | }; | |
| 3740 | }, [isComposerScrollCollapsed]); | |
| 3741 | ||
| 3742 | useEffect(() => { | |
| 3743 | if (!canTrackComposerScrollGesture) return; | |
| 3744 | ||
| 3745 | const finishScrollGesture = () => { | |
| 3746 | if (composerScrollCollapseTimeoutRef.current !== null) { | |
| 3747 | window.clearTimeout(composerScrollCollapseTimeoutRef.current); | |
| 3748 | } | |
| 3749 | composerScrollCollapseTimeoutRef.current = null; | |
| 3750 | resetComposerScrollGesture(composerScrollGestureRef.current); | |
| 3751 | }; | |
| 3752 | const handleTimelineWheel = (event: WheelEvent) => { | |
| 3753 | const activeElement = document.activeElement; | |
| 3754 | const isPromptEditorFocused = | |
| 3755 | activeElement instanceof HTMLElement && | |
| 3756 | activeElement.isContentEditable && | |
| 3757 | composerFormRef.current?.contains(activeElement) === true; | |
| 3758 | if (event.ctrlKey || !(event.target instanceof Element)) { | |
| 3759 | return; | |
| 3760 | } | |
| 3761 | ||
| 3762 | const scrollNode = getTimelineScrollableNode(); | |
| 3763 | if (!scrollNode) return; | |
| 3764 | const targetsTimeline = scrollNode.contains(event.target); | |
| 3765 | if (!targetsTimeline && !composerScrollGestureRef.current.collapseSuppressed) return; | |
| 3766 | ||
| 3767 | if (composerScrollCollapseTimeoutRef.current !== null) { | |
| 3768 | window.clearTimeout(composerScrollCollapseTimeoutRef.current); | |
| 3769 | } | |
| 3770 | composerScrollCollapseTimeoutRef.current = window.setTimeout( | |
| 3771 | finishScrollGesture, | |
| 3772 | COMPOSER_SCROLL_GESTURE_RESET_MS, | |
| 3773 | ); | |
| 3774 | ||
| 3775 | const canScrollInGestureDirection = | |
| 3776 | targetsTimeline && | |
| 3777 | (event.deltaY < 0 | |
| 3778 | ? scrollNode.scrollTop > 0 | |
| 3779 | : scrollNode.scrollTop < scrollNode.scrollHeight - scrollNode.clientHeight); | |
| 3780 | const deltaPx = | |
| 3781 | Math.abs(event.deltaY) * | |
| 3782 | (event.deltaMode === WheelEvent.DOM_DELTA_LINE | |
| 3783 | ? 16 | |
| 3784 | : event.deltaMode === WheelEvent.DOM_DELTA_PAGE | |
| 3785 | ? scrollNode.clientHeight | |
| 3786 | : 1); | |
| 3787 | const shouldCollapse = recordComposerScrollGestureEvent(composerScrollGestureRef.current, { | |
| 3788 | now: window.performance.now(), | |
| 3789 | deltaPx, | |
| 3790 | collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX, | |
| 3791 | collapseEligible: | |
| 3792 | targetsTimeline && composerScrollCollapseEligibleRef.current && isPromptEditorFocused, | |
| 3793 | canScrollInGestureDirection, | |
| 3794 | scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(), | |
| 3795 | }); | |
| 3796 | if (!shouldCollapse) { | |
| 3797 | return; | |
| 3798 | } | |
| 3799 | ||
| 3800 | setIsComposerScrollCollapsed(true); | |
| 3801 | }; | |
| 3802 | ||
| 3803 | document.addEventListener("wheel", handleTimelineWheel, { capture: true, passive: true }); | |
| 3804 | return () => { | |
| 3805 | document.removeEventListener("wheel", handleTimelineWheel, true); | |
| 3806 | finishScrollGesture(); | |
| 3807 | }; | |
| 3808 | }, [ | |
| 3809 | activeThreadId, | |
| 3810 | canTrackComposerScrollGesture, | |
| 3811 | getTimelineScrollableNode, | |
| 3812 | isTimelineAtLogicalEnd, | |
| 3813 | ]); | |
| 3814 | ||
| 3815 | const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0; | |
| 3816 | const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact; | |
| 3817 | const restingBlockDefs = [ | |
| 3818 | ...(providerTraitsPicker | |
| 3819 | ? [ | |
| 3820 | { | |
| 3821 | id: "traits", | |
| 3822 | content: ( | |
| 3823 | <> | |
| 3824 | <ComposerControlSeparator size={composerControlsInStrip ? "xs" : "sm"} /> | |
| 3825 | {composerControlsInStrip ? restingProviderTraitsPicker : providerTraitsPicker} | |
| 3826 | </> | |
| 3827 | ), | |
| 3828 | }, | |
| 3829 | ] | |
| 3830 | : []), | |
| 3831 | { | |
| 3832 | id: "mode", | |
| 3833 | content: ( | |
| 3834 | <ComposerFooterModeControls | |
| 3835 | showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} | |
| 3836 | interactionMode={interactionMode} | |
| 3837 | runtimeMode={runtimeMode} | |
| 3838 | size={composerControlsInStrip ? "xs" : "sm"} | |
| 3839 | onToggleInteractionMode={toggleInteractionMode} | |
| 3840 | onRuntimeModeChange={handleRuntimeModeChange} | |
| 3841 | /> | |
| 3842 | ), | |
| 3843 | }, | |
| 3844 | ]; | |
| 3845 | const hiddenRestingBlockIds = restingBlockDefs | |
| 3846 | .slice(restingBlockDefs.length - restingHiddenBlockCount) | |
| 3847 | .map((def) => def.id); | |
| 3848 | const composerControls = noProviderAvailable ? ( | |
| 3849 | <Button | |
| 3850 | type="button" | |
| 3851 | size="sm" | |
| 3852 | variant="ghost" | |
| 3853 | disabled | |
| 3854 | data-chat-provider-unavailable="true" | |
| 3855 | className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3" | |
| 3856 | > | |
| 3857 | <CircleAlertIcon className="size-4" /> | |
| 3858 | No provider available | |
| 3859 | </Button> | |
| 3860 | ) : ( | |
| 3861 | <> | |
| 3862 | {composerControlsInStrip && restingControlsHaveLeadingContext ? ( | |
| 3863 | <ComposerControlSeparator | |
| 3864 | size="xs" | |
| 3865 | className="@max-[400px]/composer-surface:hidden" | |
| 3866 | data-resting-controls-separator="true" | |
| 3867 | /> | |
| 3868 | ) : null} | |
| 3869 | <ProviderModelPicker | |
| 3870 | isComposerOwned | |
| 3871 | compact={composerControlsCompact} | |
| 3872 | activeInstanceId={selectedInstanceId} | |
| 3873 | model={selectedModelForPickerWithCustomFallback} | |
| 3874 | lockedProvider={lockedProvider} | |
| 3875 | lockedContinuationGroupKey={lockedContinuationGroupKey} | |
| 3876 | instanceEntries={providerInstanceEntries} | |
| 3877 | keybindings={keybindings} | |
| 3878 | modelOptionsByInstance={modelOptionsByInstance} | |
| 3879 | size={composerControlsInStrip ? "xs" : "sm"} | |
| 3880 | triggerClassName={ | |
| 3881 | composerControlsInStrip | |
| 3882 | ? "min-w-13 shrink text-xs! @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:w-0 @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:flex-none" | |
| 3883 | : "-ms-2.5" | |
| 3884 | } | |
| 3885 | terminalOpen={terminalOpen} | |
| 3886 | open={isComposerModelPickerOpen} | |
| 3887 | instanceIndicatorBackground={ | |
| 3888 | composerControlsInStrip | |
| 3889 | ? "color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent)" | |
| 3890 | : "var(--contrast-input)" | |
| 3891 | } | |
| 3892 | {...(composerProviderState.modelPickerIconClassName || composerControlsInStrip | |
| 3893 | ? { | |
| 3894 | activeProviderIconClassName: cn( | |
| 3895 | composerProviderState.modelPickerIconClassName, | |
| 3896 | composerControlsInStrip && | |
| 3897 | "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70!", | |
| 3898 | ), | |
| 3899 | } | |
| 3900 | : {})} | |
| 3901 | onOpenChange={setIsComposerModelPickerOpen} | |
| 3902 | getModelDisabledReason={getModelDisabledReason} | |
| 3903 | onInstanceModelChange={onProviderModelSelect} | |
| 3904 | /> | |
| 3905 | ||
| 3906 | {composerControlsCompact ? ( | |
| 3907 | <CompactComposerControlsMenu | |
| 3908 | interactionMode={interactionMode} | |
| 3909 | runtimeMode={runtimeMode} | |
| 3910 | showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} | |
| 3911 | traitsMenuContent={providerTraitsMenuContent} | |
| 3912 | onToggleInteractionMode={toggleInteractionMode} | |
| 3913 | onRuntimeModeChange={handleRuntimeModeChange} | |
| 3914 | /> | |
| 3915 | ) : ( | |
| 3916 | <> | |
| 3917 | {restingBlockDefs.map((def, index) => { | |
| 3918 | if (!composerControlsInStrip) { | |
| 3919 | return <Fragment key={def.id}>{def.content}</Fragment>; | |
| 3920 | } | |
| 3921 | const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount; | |
| 3922 | return ( | |
| 3923 | <div | |
| 3924 | key={`${def.id}:${hidden ? "hidden" : "visible"}`} | |
| 3925 | data-resting-block={def.id} | |
| 3926 | aria-hidden={hidden || undefined} | |
| 3927 | inert={hidden || undefined} | |
| 3928 | className={cn( | |
| 3929 | "flex w-max min-w-max shrink-0 items-center gap-1", | |
| 3930 | hidden && "pointer-events-none invisible absolute", | |
| 3931 | )} | |
| 3932 | > | |
| 3933 | {def.content} | |
| 3934 | </div> | |
| 3935 | ); | |
| 3936 | })} | |
| 3937 | {composerControlsInStrip ? ( | |
| 3938 | <div | |
| 3939 | key={hiddenRestingBlockIds.length > 0 ? "overflow:visible" : "overflow:hidden"} | |
| 3940 | data-resting-controls-overflow | |
| 3941 | aria-hidden={hiddenRestingBlockIds.length === 0 || undefined} | |
| 3942 | inert={hiddenRestingBlockIds.length === 0 || undefined} | |
| 3943 | className={cn( | |
| 3944 | "min-w-0 shrink-0", | |
| 3945 | hiddenRestingBlockIds.length === 0 && "pointer-events-none invisible absolute", | |
| 3946 | )} | |
| 3947 | > | |
| 3948 | <CompactComposerControlsMenu | |
| 3949 | interactionMode={interactionMode} | |
| 3950 | runtimeMode={runtimeMode} | |
| 3951 | size="xs" | |
| 3952 | showInteractionModeToggle={ | |
| 3953 | composerProviderControls.showInteractionModeToggle && | |
| 3954 | hiddenRestingBlockIds.includes("mode") | |
| 3955 | } | |
| 3956 | traitsMenuContent={ | |
| 3957 | hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined | |
| 3958 | } | |
| 3959 | onToggleInteractionMode={toggleInteractionMode} | |
| 3960 | onRuntimeModeChange={handleRuntimeModeChange} | |
| 3961 | /> | |
| 3962 | </div> | |
| 3963 | ) : null} | |
| 3964 | </> | |
| 3965 | )} | |
| 3966 | </> | |
| 3967 | ); | |
| 2989 | 3968 | const showTasksTab = |
| 2990 | 3969 | !hasBannerItems && |
| 2991 | 3970 | !showComposerTopDrawer && |
| ... | ... | @@ -3318,8 +4297,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3318 | 4297 | ); |
| 3319 | 4298 | |
| 3320 | 4299 | const insertComposerTextAtEnd = useCallback<ChatComposerHandle["insertTextAtEnd"]>( |
| 3321 | (text, options) => insertComposerText(text, "end", options), | |
| 3322 | [insertComposerText], | |
| 4300 | (text, options) => { | |
| 4301 | const inserted = insertComposerText(text, "end", options); | |
| 4302 | if (inserted && isComposerCollapsedMobile) { | |
| 4303 | // The expanded editor is hidden at phone widths, so its scheduled | |
| 4304 | // focus cannot expand the composer by itself. Reveal it before the | |
| 4305 | // focus frame runs to preserve type-to-focus and external inserts. | |
| 4306 | expandMobileComposer(); | |
| 4307 | } | |
| 4308 | return inserted; | |
| 4309 | }, | |
| 4310 | [expandMobileComposer, insertComposerText, isComposerCollapsedMobile], | |
| 3323 | 4311 | ); |
| 3324 | 4312 | |
| 3325 | 4313 | // File-tree drags land as mentions. Handled in the capture phase so the |
| ... | ... | @@ -3364,10 +4352,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3364 | 4352 | void onImplementPlanInNewThread(); |
| 3365 | 4353 | }, [onImplementPlanInNewThread]); |
| 3366 | 4354 | const scheduleComposerCollapseCheck = useCallback(() => { |
| 3367 | if (!isMobileViewport) { | |
| 3368 | return; | |
| 3369 | } | |
| 3370 | if (mobileComposerExpandInFlightRef.current) { | |
| 4355 | if (isMobileViewport && mobileComposerExpandInFlightRef.current) { | |
| 3371 | 4356 | return; |
| 3372 | 4357 | } |
| 3373 | 4358 | if (composerBlurFrameRef.current !== null) { |
| ... | ... | @@ -3375,7 +4360,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3375 | 4360 | } |
| 3376 | 4361 | composerBlurFrameRef.current = window.requestAnimationFrame(() => { |
| 3377 | 4362 | composerBlurFrameRef.current = null; |
| 3378 | if (mobileComposerExpandInFlightRef.current) { | |
| 4363 | if (isMobileViewport && mobileComposerExpandInFlightRef.current) { | |
| 4364 | return; | |
| 4365 | } | |
| 4366 | if (!isMobileViewport && desktopOutsidePointerInFlightRef.current) { | |
| 3379 | 4367 | return; |
| 3380 | 4368 | } |
| 3381 | 4369 | const composerSurface = composerSurfaceRef.current; |
| ... | ... | @@ -3395,6 +4383,73 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3395 | 4383 | }); |
| 3396 | 4384 | }, [isMobileViewport]); |
| 3397 | 4385 | |
| 4386 | useEffect(() => { | |
| 4387 | if (isMobileViewport || !isComposerFocused) return; | |
| 4388 | ||
| 4389 | const isInsideDesktopComposerFocusScope = (target: EventTarget | null) => | |
| 4390 | Boolean( | |
| 4391 | target instanceof Node && | |
| 4392 | (composerFormRef.current?.contains(target) || | |
| 4393 | (target instanceof Element && isInsideComposerFloatingLayer(target))), | |
| 4394 | ); | |
| 4395 | const handleFocusIn = (event: FocusEvent) => { | |
| 4396 | if (!isInsideDesktopComposerFocusScope(event.target)) { | |
| 4397 | if (desktopOutsidePointerInFlightRef.current) { | |
| 4398 | return; | |
| 4399 | } | |
| 4400 | setIsComposerFocused(false); | |
| 4401 | } | |
| 4402 | }; | |
| 4403 | const handlePointerDown = (event: PointerEvent) => { | |
| 4404 | if (!isInsideDesktopComposerFocusScope(event.target)) { | |
| 4405 | desktopOutsidePointerInFlightRef.current = true; | |
| 4406 | if (desktopOutsidePointerReleaseTimeoutRef.current !== null) { | |
| 4407 | window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current); | |
| 4408 | desktopOutsidePointerReleaseTimeoutRef.current = null; | |
| 4409 | } | |
| 4410 | } | |
| 4411 | }; | |
| 4412 | const finishOutsidePointerInteraction = () => { | |
| 4413 | desktopOutsidePointerInFlightRef.current = false; | |
| 4414 | if (desktopOutsidePointerReleaseTimeoutRef.current !== null) { | |
| 4415 | window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current); | |
| 4416 | desktopOutsidePointerReleaseTimeoutRef.current = null; | |
| 4417 | } | |
| 4418 | scheduleComposerCollapseCheck(); | |
| 4419 | }; | |
| 4420 | const handlePointerUp = () => { | |
| 4421 | if (!desktopOutsidePointerInFlightRef.current) return; | |
| 4422 | desktopOutsidePointerReleaseTimeoutRef.current = window.setTimeout(() => { | |
| 4423 | if (desktopOutsidePointerInFlightRef.current) { | |
| 4424 | finishOutsidePointerInteraction(); | |
| 4425 | } | |
| 4426 | }, 0); | |
| 4427 | }; | |
| 4428 | const handleClick = () => { | |
| 4429 | if (desktopOutsidePointerInFlightRef.current) { | |
| 4430 | finishOutsidePointerInteraction(); | |
| 4431 | } | |
| 4432 | }; | |
| 4433 | ||
| 4434 | document.addEventListener("focusin", handleFocusIn, true); | |
| 4435 | document.addEventListener("pointerdown", handlePointerDown, true); | |
| 4436 | document.addEventListener("pointerup", handlePointerUp, true); | |
| 4437 | document.addEventListener("pointercancel", handlePointerUp, true); | |
| 4438 | document.addEventListener("click", handleClick); | |
| 4439 | return () => { | |
| 4440 | document.removeEventListener("focusin", handleFocusIn, true); | |
| 4441 | document.removeEventListener("pointerdown", handlePointerDown, true); | |
| 4442 | document.removeEventListener("pointerup", handlePointerUp, true); | |
| 4443 | document.removeEventListener("pointercancel", handlePointerUp, true); | |
| 4444 | document.removeEventListener("click", handleClick); | |
| 4445 | if (desktopOutsidePointerReleaseTimeoutRef.current !== null) { | |
| 4446 | window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current); | |
| 4447 | desktopOutsidePointerReleaseTimeoutRef.current = null; | |
| 4448 | } | |
| 4449 | desktopOutsidePointerInFlightRef.current = false; | |
| 4450 | }; | |
| 4451 | }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck]); | |
| 4452 | ||
| 3398 | 4453 | useEffect(() => { |
| 3399 | 4454 | return () => { |
| 3400 | 4455 | if (composerBlurFrameRef.current !== null) { |
| ... | ... | @@ -3421,6 +4476,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3421 | 4476 | focusAt: (cursor: number) => { |
| 3422 | 4477 | composerEditorRef.current?.focusAt(cursor); |
| 3423 | 4478 | }, |
| 4479 | restoreAfterTimelineReachedEnd: () => { | |
| 4480 | setIsComposerScrollCollapsed(false); | |
| 4481 | }, | |
| 3424 | 4482 | addDroppedFiles: (files: File[]) => { |
| 3425 | 4483 | void addComposerAttachments(files); |
| 3426 | 4484 | focusComposer(); |
| ... | ... | @@ -3564,8 +4622,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3564 | 4622 | <form |
| 3565 | 4623 | ref={composerFormRef} |
| 3566 | 4624 | onSubmit={submitComposer} |
| 4625 | onPointerDownCapture={(event) => { | |
| 4626 | const target = event.target; | |
| 4627 | if (isInsideRestingComposerControlScope(target)) return; | |
| 4628 | if (!(target instanceof Element)) return; | |
| 4629 | const isInteractive = Boolean( | |
| 4630 | target.closest('button, a, input, select, [role="button"], [role="menuitem"]'), | |
| 4631 | ); | |
| 4632 | if (isInteractive) return; | |
| 4633 | ||
| 4634 | setIsComposerScrollCollapsed(false); | |
| 4635 | if (isComposerResting && !target.closest('[data-testid="composer-editor"]')) { | |
| 4636 | // Clicking resting-surface padding would otherwise blur the still | |
| 4637 | // focused editor after pointerdown: expansion starts, the blur check | |
| 4638 | // runs, and it immediately collapses again. Treat that padding like | |
| 4639 | // the editor without stealing native caret placement from text. | |
| 4640 | event.preventDefault(); | |
| 4641 | setIsComposerFocused(true); | |
| 4642 | scheduleComposerFocus(); | |
| 4643 | } | |
| 4644 | }} | |
| 3567 | 4645 | onFocusCapture={(event) => { |
| 3568 | 4646 | const activeElement = event.target; |
| 4647 | if (composerControlsInStrip && isInsideRestingComposerControlScope(activeElement)) { | |
| 4648 | return; | |
| 4649 | } | |
| 3569 | 4650 | if ( |
| 3570 | 4651 | isComposerCollapsedMobile && |
| 3571 | 4652 | activeElement instanceof HTMLElement && |
| ... | ... | @@ -3573,6 +4654,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3573 | 4654 | ) { |
| 3574 | 4655 | return; |
| 3575 | 4656 | } |
| 4657 | // Focus returning from another window or tab lands on the element | |
| 4658 | // that already held it, which is not a request to expand a | |
| 4659 | // scroll-collapsed composer. | |
| 4660 | if (!windowRefocusInFlightRef.current) { | |
| 4661 | setIsComposerScrollCollapsed(false); | |
| 4662 | } | |
| 3576 | 4663 | if (composerBlurFrameRef.current !== null) { |
| 3577 | 4664 | window.cancelAnimationFrame(composerBlurFrameRef.current); |
| 3578 | 4665 | composerBlurFrameRef.current = null; |
| ... | ... | @@ -3582,13 +4669,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3582 | 4669 | onBlurCapture={() => { |
| 3583 | 4670 | scheduleComposerCollapseCheck(); |
| 3584 | 4671 | }} |
| 3585 | onDragEnterCapture={composerMentionDragHandlers.onDragEnter} | |
| 3586 | onDragOverCapture={composerMentionDragHandlers.onDragOver} | |
| 3587 | onDragLeaveCapture={onComposerMentionDragLeaveCapture} | |
| 3588 | onDropCapture={composerMentionDragHandlers.onDrop} | |
| 4672 | onDragEnterCapture={(event) => { | |
| 4673 | if (isInsideRestingComposerControlScope(event.target)) return; | |
| 4674 | composerMentionDragHandlers.onDragEnter(event); | |
| 4675 | }} | |
| 4676 | onDragOverCapture={(event) => { | |
| 4677 | if (isInsideRestingComposerControlScope(event.target)) return; | |
| 4678 | composerMentionDragHandlers.onDragOver(event); | |
| 4679 | }} | |
| 4680 | onDragLeaveCapture={(event) => { | |
| 4681 | if (isInsideRestingComposerControlScope(event.target)) return; | |
| 4682 | onComposerMentionDragLeaveCapture(event); | |
| 4683 | }} | |
| 4684 | onDropCapture={(event) => { | |
| 4685 | if (isInsideRestingComposerControlScope(event.target)) return; | |
| 4686 | composerMentionDragHandlers.onDrop(event); | |
| 4687 | }} | |
| 3589 | 4688 | className="mx-auto w-full min-w-0 max-w-3xl" |
| 3590 | 4689 | data-chat-composer-form="true" |
| 3591 | 4690 | > |
| 4691 | {composerControlsInStrip && restingControlsHost | |
| 4692 | ? createPortal( | |
| 4693 | <div | |
| 4694 | ref={restingComposerControlsRef} | |
| 4695 | data-chat-composer-resting-controls="true" | |
| 4696 | aria-hidden={restingControlsVisible ? undefined : true} | |
| 4697 | inert={restingControlsVisible ? undefined : true} | |
| 4698 | className={cn( | |
| 4699 | "relative flex w-max min-w-0 max-w-full items-center gap-1 font-normal text-muted-foreground/70 [&_button]:text-xs!", | |
| 4700 | !restingControlsVisible && "invisible", | |
| 4701 | )} | |
| 4702 | > | |
| 4703 | {composerControls} | |
| 4704 | </div>, | |
| 4705 | restingControlsHost, | |
| 4706 | ) | |
| 4707 | : null} | |
| 3592 | 4708 | <ComposerBanner.Dock> |
| 3593 | 4709 | <ComposerBanner.Column> |
| 3594 | 4710 | <ComposerBannerStack |
| ... | ... | @@ -3746,7 +4862,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3746 | 4862 | ) : null} |
| 3747 | 4863 | </ComposerBanner.Dock> |
| 3748 | 4864 | <div className="relative"> |
| 3749 | <ComposerSurface.Main className={composerProviderState.composerFrameClassName}> | |
| 4865 | <ComposerSurface.Main | |
| 4866 | ref={composerMainSurfaceRef} | |
| 4867 | className={composerProviderState.composerFrameClassName} | |
| 4868 | > | |
| 3750 | 4869 | <div |
| 3751 | 4870 | ref={composerSurfaceRef} |
| 3752 | 4871 | data-chat-composer-surface="true" |
| ... | ... | @@ -3762,6 +4881,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3762 | 4881 | <div className="flex items-center justify-between gap-2 px-3 py-2"> |
| 3763 | 4882 | <button |
| 3764 | 4883 | type="button" |
| 4884 | data-chat-composer-transition-prompt="true" | |
| 3765 | 4885 | className={cn( |
| 3766 | 4886 | "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none", |
| 3767 | 4887 | (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim()) |
| ... | ... | @@ -3778,8 +4898,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3778 | 4898 | : prompt.trim() || |
| 3779 | 4899 | (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} |
| 3780 | 4900 | </button> |
| 4901 | {collapsedComposerImagePreviews} | |
| 3781 | 4902 | <button |
| 3782 | 4903 | type="button" |
| 4904 | data-chat-composer-transition-actions="true" | |
| 3783 | 4905 | className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30" |
| 3784 | 4906 | disabled={collapsedComposerPrimaryActionDisabled} |
| 3785 | 4907 | aria-label={collapsedComposerPrimaryActionLabel} |
| ... | ... | @@ -3804,11 +4926,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3804 | 4926 | |
| 3805 | 4927 | <div |
| 3806 | 4928 | ref={setComposerMenuAnchor} |
| 4929 | data-chat-composer-body="true" | |
| 3807 | 4930 | className={cn( |
| 3808 | 4931 | "relative px-3 pb-2 sm:px-4", |
| 3809 | 4932 | "pt-3.5 sm:pt-4", |
| 3810 | 4933 | isComposerApprovalState && "pb-3 sm:pb-4", |
| 3811 | 4934 | isComposerCollapsedMobile && "hidden", |
| 4935 | isComposerResting && "py-2 sm:py-2", | |
| 3812 | 4936 | )} |
| 3813 | 4937 | > |
| 3814 | 4938 | {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( |
| ... | ... | @@ -3904,25 +5028,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3904 | 5028 | !isComposerApprovalState && |
| 3905 | 5029 | pendingUserInputs.length === 0 && |
| 3906 | 5030 | (composerVideos.length > 0 || |
| 3907 | composerImages.some( | |
| 3908 | (image) => | |
| 3909 | !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), | |
| 3910 | )) && ( | |
| 5031 | (!isComposerResting && standaloneComposerImages.length > 0)) && ( | |
| 3911 | 5032 | <div className="mb-3 flex flex-wrap gap-2"> |
| 3912 | {composerImages | |
| 3913 | .filter( | |
| 3914 | (image) => | |
| 3915 | !composerPreviewAnnotations.some( | |
| 3916 | (annotation) => annotation.id === image.id, | |
| 3917 | ), | |
| 3918 | ) | |
| 3919 | .map((image) => { | |
| 5033 | {!isComposerResting && | |
| 5034 | standaloneComposerImages.map((image) => { | |
| 3920 | 5035 | const upload = supportsAttachmentUploads |
| 3921 | 5036 | ? uploadsByImageId[image.id] |
| 3922 | 5037 | : undefined; |
| 3923 | 5038 | return ( |
| 3924 | 5039 | <div |
| 3925 | 5040 | key={image.id} |
| 5041 | data-chat-composer-expanded-image="true" | |
| 3926 | 5042 | className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" |
| 3927 | 5043 | > |
| 3928 | 5044 | {image.previewUrl ? ( |
| ... | ... | @@ -4172,7 +5288,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4172 | 5288 | </div> |
| 4173 | 5289 | )} |
| 4174 | 5290 | |
| 4175 | <div className="relative"> | |
| 5291 | <div | |
| 5292 | className={cn( | |
| 5293 | "relative", | |
| 5294 | isComposerResting && "flex min-w-0 items-center gap-1", | |
| 5295 | isComposerResting && (showComposerAttachAction ? "pr-20" : "pr-12"), | |
| 5296 | )} | |
| 5297 | > | |
| 4176 | 5298 | <ComposerPromptEditor |
| 4177 | 5299 | editorRef={composerEditorRef} |
| 4178 | 5300 | value={ |
| ... | ... | @@ -4189,9 +5311,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4189 | 5311 | : [] |
| 4190 | 5312 | } |
| 4191 | 5313 | skills={selectedProviderSkills} |
| 4192 | {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} | |
| 5314 | containerClassName={cn(isComposerResting && "min-w-0 flex-1")} | |
| 5315 | className={cn( | |
| 5316 | showMobilePendingAnswerActions && "max-sm:pb-11", | |
| 5317 | isComposerResting && | |
| 5318 | "max-h-8 min-h-8 overflow-hidden whitespace-nowrap! leading-8", | |
| 5319 | )} | |
| 5320 | placeholderClassName={cn( | |
| 5321 | isComposerResting && | |
| 5322 | "flex items-center overflow-hidden whitespace-nowrap leading-8", | |
| 5323 | )} | |
| 4193 | 5324 | onRemoveTerminalContext={removeComposerTerminalContextFromDraft} |
| 4194 | 5325 | onChange={onPromptChange} |
| 5326 | onVisibleSelectionChange={expandComposerForEditorChange} | |
| 4195 | 5327 | onCommandKeyDown={onComposerCommandKey} |
| 4196 | 5328 | onPageScrollKeyDown={onPageScrollKeyDown} |
| 4197 | 5329 | onPageScrollKeyUp={onPageScrollKeyUp} |
| ... | ... | @@ -4215,6 +5347,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4215 | 5347 | } |
| 4216 | 5348 | disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} |
| 4217 | 5349 | /> |
| 5350 | {isComposerResting ? collapsedComposerImagePreviews : null} | |
| 4218 | 5351 | {showMobilePendingAnswerActions ? ( |
| 4219 | 5352 | <div |
| 4220 | 5353 | data-chat-composer-mobile-pending-actions="true" |
| ... | ... | @@ -4260,94 +5393,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4260 | 5393 | pendingUserInputs.length > 0 && "pt-2", |
| 4261 | 5394 | isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", |
| 4262 | 5395 | showMobilePendingAnswerActions && "hidden sm:flex", |
| 5396 | isComposerResting && | |
| 5397 | "absolute bottom-px right-px z-10 h-12 w-auto gap-0 py-0 sm:gap-0 sm:py-0", | |
| 4263 | 5398 | )} |
| 4264 | 5399 | > |
| 4265 | 5400 | <div |
| 4266 | 5401 | ref={composerFooterControlsRef} |
| 5402 | data-chat-composer-controls="left" | |
| 4267 | 5403 | data-chat-composer-footer-controls="true" |
| 4268 | className="-m-1 -ms-3.5 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 ps-3.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" | |
| 4269 | > | |
| 4270 | {noProviderAvailable ? ( | |
| 4271 | <Button | |
| 4272 | type="button" | |
| 4273 | size="sm" | |
| 4274 | variant="ghost" | |
| 4275 | disabled | |
| 4276 | data-chat-provider-unavailable="true" | |
| 4277 | className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3" | |
| 4278 | > | |
| 4279 | <CircleAlertIcon className="size-4" /> | |
| 4280 | No provider available | |
| 4281 | </Button> | |
| 4282 | ) : ( | |
| 4283 | <ProviderModelPicker | |
| 4284 | compact={isComposerFooterCompact} | |
| 4285 | activeInstanceId={selectedInstanceId} | |
| 4286 | model={selectedModelForPickerWithCustomFallback} | |
| 4287 | lockedProvider={lockedProvider} | |
| 4288 | lockedContinuationGroupKey={lockedContinuationGroupKey} | |
| 4289 | instanceEntries={providerInstanceEntries} | |
| 4290 | keybindings={keybindings} | |
| 4291 | modelOptionsByInstance={modelOptionsByInstance} | |
| 4292 | triggerClassName="-ms-2.5" | |
| 4293 | terminalOpen={terminalOpen} | |
| 4294 | open={isComposerModelPickerOpen} | |
| 4295 | {...(composerProviderState.modelPickerIconClassName | |
| 4296 | ? { | |
| 4297 | activeProviderIconClassName: | |
| 4298 | composerProviderState.modelPickerIconClassName, | |
| 4299 | } | |
| 4300 | : {})} | |
| 4301 | onOpenChange={(open) => { | |
| 4302 | setIsComposerModelPickerOpen(open); | |
| 4303 | }} | |
| 4304 | getModelDisabledReason={getModelDisabledReason} | |
| 4305 | onInstanceModelChange={onProviderModelSelect} | |
| 4306 | /> | |
| 4307 | )} | |
| 4308 | ||
| 4309 | {isComposerFooterCompact ? ( | |
| 4310 | <CompactComposerControlsMenu | |
| 4311 | interactionMode={interactionMode} | |
| 4312 | runtimeMode={runtimeMode} | |
| 4313 | showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} | |
| 4314 | traitsMenuContent={providerTraitsMenuContent} | |
| 4315 | onToggleInteractionMode={toggleInteractionMode} | |
| 4316 | onRuntimeModeChange={handleRuntimeModeChange} | |
| 4317 | /> | |
| 4318 | ) : ( | |
| 4319 | <> | |
| 4320 | {providerTraitsPicker ? ( | |
| 4321 | <> | |
| 4322 | <Separator | |
| 4323 | orientation="vertical" | |
| 4324 | className="mx-0.5 hidden h-4 sm:block" | |
| 4325 | /> | |
| 4326 | {providerTraitsPicker} | |
| 4327 | </> | |
| 4328 | ) : null} | |
| 4329 | <ComposerFooterModeControls | |
| 4330 | showInteractionModeToggle={ | |
| 4331 | composerProviderControls.showInteractionModeToggle | |
| 4332 | } | |
| 4333 | interactionMode={interactionMode} | |
| 4334 | runtimeMode={runtimeMode} | |
| 4335 | onToggleInteractionMode={toggleInteractionMode} | |
| 4336 | onRuntimeModeChange={handleRuntimeModeChange} | |
| 4337 | /> | |
| 4338 | </> | |
| 5404 | className={cn( | |
| 5405 | "-m-1 -ms-3.5 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 ps-3.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", | |
| 5406 | isComposerResting && "hidden", | |
| 4339 | 5407 | )} |
| 5408 | > | |
| 5409 | {composerControlsInStrip ? null : composerControls} | |
| 4340 | 5410 | </div> |
| 4341 | 5411 | |
| 4342 | 5412 | {/* Right side: send / stop button */} |
| 4343 | 5413 | <div |
| 4344 | 5414 | data-chat-composer-actions="right" |
| 5415 | data-chat-composer-transition-actions="true" | |
| 4345 | 5416 | data-chat-composer-primary-actions-compact={ |
| 4346 | 5417 | isComposerPrimaryActionsCompact ? "true" : "false" |
| 4347 | 5418 | } |
| 4348 | 5419 | className="flex shrink-0 flex-nowrap items-center justify-end gap-2" |
| 4349 | 5420 | > |
| 4350 | {fileStagingLimit !== null && pendingUserInputs.length === 0 ? ( | |
| 5421 | {showComposerAttachAction ? ( | |
| 4351 | 5422 | <> |
| 4352 | 5423 | <input |
| 4353 | 5424 | ref={attachmentInputRef} |
| ... | ... | @@ -4381,7 +5452,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4381 | 5452 | </> |
| 4382 | 5453 | ) : null} |
| 4383 | 5454 | <ComposerFooterPrimaryActions |
| 4384 | compact={isComposerPrimaryActionsCompact} | |
| 5455 | compact={isComposerResting || isComposerPrimaryActionsCompact} | |
| 4385 | 5456 | activeContextWindow={ |
| 4386 | 5457 | settings.contextWindowMeterEnabled ? activeContextWindow : null |
| 4387 | 5458 | } |
| ... | ... | @@ -4402,8 +5473,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4402 | 5473 | } |
| 4403 | 5474 | isPreparingWorktree={isPreparingWorktree} |
| 4404 | 5475 | hasSendableContent={composerSendState.hasSendableContent} |
| 4405 | preserveComposerFocusOnPointerDown={isMobileViewport} | |
| 5476 | preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting} | |
| 4406 | 5477 | showSendWhileRunning={isMobileViewport} |
| 5478 | showSecondaryStatus={!isComposerResting} | |
| 4407 | 5479 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} |
| 4408 | 5480 | onInterrupt={handleInterruptPrimaryAction} |
| 4409 | 5481 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} |
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+10-6| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; |
| 2 | 2 | import { memo, type ReactNode } from "react"; |
| 3 | 3 | import { EllipsisIcon } from "lucide-react"; |
| 4 | import { Button } from "../ui/button"; | |
| 5 | 4 | import { |
| 6 | 5 | Menu, |
| 7 | 6 | MenuPopup, |
| ... | ... | @@ -10,30 +9,35 @@ import { |
| 10 | 9 | MenuSeparator as MenuDivider, |
| 11 | 10 | MenuTrigger, |
| 12 | 11 | } from "../ui/menu"; |
| 12 | import { ComposerControl, ComposerControlIcon } from "./ComposerControl"; | |
| 13 | import { composerFloatingLayerProps } from "./composerEventScope"; | |
| 13 | 14 | |
| 14 | 15 | export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { |
| 15 | 16 | interactionMode: ProviderInteractionMode; |
| 16 | 17 | runtimeMode: RuntimeMode; |
| 17 | 18 | showInteractionModeToggle: boolean; |
| 18 | 19 | traitsMenuContent?: ReactNode; |
| 20 | size?: "sm" | "xs"; | |
| 19 | 21 | onToggleInteractionMode: () => void; |
| 20 | 22 | onRuntimeModeChange: (mode: RuntimeMode) => void; |
| 21 | 23 | }) { |
| 24 | const size = props.size ?? "sm"; | |
| 25 | ||
| 22 | 26 | return ( |
| 23 | 27 | <Menu> |
| 24 | 28 | <MenuTrigger |
| 25 | 29 | render={ |
| 26 | <Button | |
| 27 | size="sm" | |
| 30 | <ComposerControl | |
| 31 | size={size} | |
| 28 | 32 | variant="ghost" |
| 29 | className="shrink-0 px-2 text-muted-foreground/70 hover:text-foreground/80" | |
| 33 | className={size === "xs" ? "shrink-0" : "shrink-0 px-2"} | |
| 30 | 34 | aria-label="More composer controls" |
| 31 | 35 | /> |
| 32 | 36 | } |
| 33 | 37 | > |
| 34 | <EllipsisIcon aria-hidden="true" className="size-4" /> | |
| 38 | <ComposerControlIcon icon={EllipsisIcon} size={size} /> | |
| 35 | 39 | </MenuTrigger> |
| 36 | <MenuPopup align="start"> | |
| 40 | <MenuPopup align="start" {...composerFloatingLayerProps}> | |
| 37 | 41 | {props.traitsMenuContent ? ( |
| 38 | 42 | <> |
| 39 | 43 | {props.traitsMenuContent} |
apps/web/src/components/chat/ComposerControl.test.tsx created+76| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 2 | import { BotIcon } from "lucide-react"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { | |
| 6 | ComposerControl, | |
| 7 | ComposerControlChevron, | |
| 8 | ComposerControlIcon, | |
| 9 | ComposerControlSeparator, | |
| 10 | } from "./ComposerControl"; | |
| 11 | ||
| 12 | describe("ComposerControl", () => { | |
| 13 | it("preserves the expanded composer geometry by default", () => { | |
| 14 | const markup = renderToStaticMarkup(<ComposerControl>Model</ComposerControl>); | |
| 15 | ||
| 16 | expect(markup).toContain("h-7"); | |
| 17 | expect(markup).toContain("min-h-7"); | |
| 18 | expect(markup).toContain("gap-1.5"); | |
| 19 | expect(markup).toContain("px-2.5"); | |
| 20 | }); | |
| 21 | ||
| 22 | it("uses the shared xs geometry for resting controls", () => { | |
| 23 | const markup = renderToStaticMarkup( | |
| 24 | <ComposerControl size="xs"> | |
| 25 | Model | |
| 26 | <ComposerControlChevron size="xs" /> | |
| 27 | </ComposerControl>, | |
| 28 | ); | |
| 29 | ||
| 30 | expect(markup).toContain("sm:h-6"); | |
| 31 | expect(markup).toContain("font-normal"); | |
| 32 | expect(markup).toContain("text-muted-foreground/70"); | |
| 33 | expect(markup).toContain("[--control-icon-color:currentColor]"); | |
| 34 | expect(markup).toContain("svg[data-composer-control-chevron]]:ms-0"); | |
| 35 | expect(markup).toContain("svg[data-composer-control-chevron]]:-me-1"); | |
| 36 | expect(markup).not.toContain("min-h-7"); | |
| 37 | expect(markup).not.toContain("gap-1.5"); | |
| 38 | expect(markup).not.toContain("px-2.5"); | |
| 39 | }); | |
| 40 | ||
| 41 | it("keeps the expanded chevron treatment unless resting overrides it", () => { | |
| 42 | const expanded = renderToStaticMarkup(<ComposerControlChevron />); | |
| 43 | const resting = renderToStaticMarkup(<ComposerControlChevron size="xs" />); | |
| 44 | ||
| 45 | expect(expanded).toContain("size-3.5"); | |
| 46 | expect(expanded).toContain("text-icon-muted"); | |
| 47 | expect(expanded).toContain('stroke-width="2.25"'); | |
| 48 | expect(resting).toContain("size-3"); | |
| 49 | expect(resting).toContain("text-current"); | |
| 50 | expect(resting).toContain("opacity-50"); | |
| 51 | expect(resting).not.toContain("size-3.5"); | |
| 52 | expect(resting).not.toContain("text-icon-muted"); | |
| 53 | }); | |
| 54 | ||
| 55 | it("owns resting icon geometry", () => { | |
| 56 | const expanded = renderToStaticMarkup(<ComposerControlIcon icon={BotIcon} />); | |
| 57 | const resting = renderToStaticMarkup(<ComposerControlIcon icon={BotIcon} size="xs" />); | |
| 58 | ||
| 59 | expect(expanded).toContain("size-4"); | |
| 60 | expect(resting).toContain("size-3"); | |
| 61 | expect(resting).not.toContain("size-4"); | |
| 62 | }); | |
| 63 | ||
| 64 | it("owns separator geometry for both composer sizes", () => { | |
| 65 | const expanded = renderToStaticMarkup(<ComposerControlSeparator />); | |
| 66 | const resting = renderToStaticMarkup( | |
| 67 | <ComposerControlSeparator size="xs" data-resting-controls-separator="true" />, | |
| 68 | ); | |
| 69 | ||
| 70 | expect(expanded).toContain("h-4"); | |
| 71 | expect(expanded).not.toContain("h-3.5!"); | |
| 72 | expect(resting).toContain("h-3.5!"); | |
| 73 | expect(resting).not.toContain("h-4"); | |
| 74 | expect(resting).toContain('data-resting-controls-separator="true"'); | |
| 75 | }); | |
| 76 | }); |
apps/web/src/components/chat/ComposerControl.tsx+63-9| ... | ... | @@ -4,19 +4,37 @@ import { ChevronDownIcon, type LucideIcon } from "lucide-react"; |
| 4 | 4 | import { cn } from "~/lib/utils"; |
| 5 | 5 | import { Button } from "../ui/button"; |
| 6 | 6 | import { SelectTrigger } from "../ui/select"; |
| 7 | import { Separator } from "../ui/separator"; | |
| 8 | ||
| 9 | export type ComposerControlSize = "sm" | "xs"; | |
| 10 | ||
| 11 | type ComposerControlProps = Omit<ComponentProps<typeof Button>, "size"> & { | |
| 12 | size?: ComposerControlSize; | |
| 13 | }; | |
| 14 | ||
| 15 | type ComposerSelectControlProps = Omit<ComponentProps<typeof SelectTrigger>, "size"> & { | |
| 16 | size?: ComposerControlSize; | |
| 17 | }; | |
| 7 | 18 | |
| 8 | 19 | const composerControlClassName = |
| 9 | "h-7 min-h-7 gap-1.5 rounded-[var(--control-radius)] px-2.5 text-secondary-label transition-none hover:text-foreground [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5"; | |
| 20 | "rounded-[var(--control-radius)] text-secondary-label transition-none hover:text-foreground [&_svg[data-composer-control-chevron]]:-mx-0.5 [&_svg[data-composer-control-icon]]:mx-0"; | |
| 21 | const expandedComposerControlClassName = "h-7 min-h-7 gap-1.5 px-2.5"; | |
| 22 | const restingComposerControlClassName = | |
| 23 | "[--control-icon-color:currentColor] font-normal text-muted-foreground/70 hover:text-foreground/80 [&_svg[data-composer-control-chevron]]:-me-1 [&_svg[data-composer-control-chevron]]:ms-0"; | |
| 10 | 24 | |
| 11 | 25 | export function ComposerControl({ |
| 12 | 26 | className, |
| 13 | 27 | size = "sm", |
| 14 | 28 | variant = "ghost", |
| 15 | 29 | ...props |
| 16 | }: ComponentProps<typeof Button>) { | |
| 30 | }: ComposerControlProps) { | |
| 17 | 31 | return ( |
| 18 | 32 | <Button |
| 19 | className={cn(composerControlClassName, className)} | |
| 33 | className={cn( | |
| 34 | composerControlClassName, | |
| 35 | size === "xs" ? restingComposerControlClassName : expandedComposerControlClassName, | |
| 36 | className, | |
| 37 | )} | |
| 20 | 38 | size={size} |
| 21 | 39 | variant={variant} |
| 22 | 40 | {...props} |
| ... | ... | @@ -28,41 +46,77 @@ export function ComposerControlIcon({ |
| 28 | 46 | icon: Icon, |
| 29 | 47 | className, |
| 30 | 48 | opticalSize = "default", |
| 49 | size = "sm", | |
| 31 | 50 | }: { |
| 32 | 51 | icon: LucideIcon; |
| 33 | 52 | className?: string | undefined; |
| 34 | 53 | opticalSize?: "default" | "large"; |
| 54 | size?: ComposerControlSize; | |
| 35 | 55 | }) { |
| 36 | 56 | return ( |
| 37 | 57 | <Icon |
| 38 | 58 | aria-hidden="true" |
| 39 | className={cn("shrink-0", opticalSize === "large" ? "size-4.5" : "size-4", className)} | |
| 59 | className={cn( | |
| 60 | "shrink-0", | |
| 61 | size === "xs" ? "size-3" : opticalSize === "large" ? "size-4.5" : "size-4", | |
| 62 | className, | |
| 63 | )} | |
| 40 | 64 | data-composer-control-icon |
| 41 | 65 | /> |
| 42 | 66 | ); |
| 43 | 67 | } |
| 44 | 68 | |
| 45 | export function ComposerControlChevron() { | |
| 69 | export function ComposerControlChevron({ | |
| 70 | className, | |
| 71 | size = "sm", | |
| 72 | }: { | |
| 73 | className?: string; | |
| 74 | size?: ComposerControlSize; | |
| 75 | } = {}) { | |
| 46 | 76 | return ( |
| 47 | 77 | <ChevronDownIcon |
| 48 | 78 | aria-hidden="true" |
| 49 | className="-mx-0.5 size-3.5 shrink-0 text-icon-muted" | |
| 79 | className={cn( | |
| 80 | "shrink-0", | |
| 81 | size === "xs" ? "size-3 text-current opacity-50" : "size-3.5 text-icon-muted", | |
| 82 | className, | |
| 83 | )} | |
| 50 | 84 | data-composer-control-chevron |
| 51 | 85 | strokeWidth={2.25} |
| 52 | 86 | /> |
| 53 | 87 | ); |
| 54 | 88 | } |
| 55 | 89 | |
| 90 | export function ComposerControlSeparator({ | |
| 91 | className, | |
| 92 | size = "sm", | |
| 93 | ...props | |
| 94 | }: Omit<ComponentProps<typeof Separator>, "orientation"> & { | |
| 95 | size?: ComposerControlSize; | |
| 96 | }) { | |
| 97 | return ( | |
| 98 | <Separator | |
| 99 | orientation="vertical" | |
| 100 | className={cn("mx-0.5 hidden sm:block", size === "xs" ? "h-3.5!" : "h-4", className)} | |
| 101 | {...props} | |
| 102 | /> | |
| 103 | ); | |
| 104 | } | |
| 105 | ||
| 56 | 106 | export function ComposerSelectControl({ |
| 57 | 107 | className, |
| 58 | 108 | size = "sm", |
| 59 | 109 | variant = "ghost", |
| 60 | 110 | ...props |
| 61 | }: ComponentProps<typeof SelectTrigger>) { | |
| 111 | }: ComposerSelectControlProps) { | |
| 62 | 112 | return ( |
| 63 | 113 | <SelectTrigger |
| 64 | className={cn(composerControlClassName, className)} | |
| 65 | icon={<ComposerControlChevron />} | |
| 114 | className={cn( | |
| 115 | composerControlClassName, | |
| 116 | size === "xs" ? restingComposerControlClassName : expandedComposerControlClassName, | |
| 117 | className, | |
| 118 | )} | |
| 119 | icon={<ComposerControlChevron size={size} />} | |
| 66 | 120 | size={size} |
| 67 | 121 | variant={variant} |
| 68 | 122 | {...props} |
apps/web/src/components/chat/ComposerPrimaryActions.tsx+2-1| ... | ... | @@ -6,6 +6,7 @@ import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../Sideb |
| 6 | 6 | import { Button } from "../ui/button"; |
| 7 | 7 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 8 | 8 | import { Spinner } from "../ui/spinner"; |
| 9 | import { composerFloatingLayerProps } from "./composerEventScope"; | |
| 9 | 10 | |
| 10 | 11 | interface PendingActionState { |
| 11 | 12 | questionIndex: number; |
| ... | ... | @@ -205,7 +206,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 205 | 206 | > |
| 206 | 207 | <ChevronDownIcon className="size-3.5" /> |
| 207 | 208 | </MenuTrigger> |
| 208 | <MenuPopup align="end" side="top"> | |
| 209 | <MenuPopup align="end" side="top" {...composerFloatingLayerProps}> | |
| 209 | 210 | <MenuItem |
| 210 | 211 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| 211 | 212 | onClick={() => void onImplementPlanInNewThread()} |
apps/web/src/components/chat/ComposerSurface.tsx+1-1| ... | ... | @@ -13,7 +13,7 @@ function Shell({ |
| 13 | 13 | data-slot="composer-shell" |
| 14 | 14 | data-with-context={contextStrip || undefined} |
| 15 | 15 | className={cn( |
| 16 | "group/composer-surface relative isolate mx-auto w-full max-w-3xl", | |
| 16 | "@container/composer-surface group/composer-surface relative isolate mx-auto w-full max-w-3xl", | |
| 17 | 17 | "[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]", |
| 18 | 18 | "dark:[--chat-composer-glass-surface:var(--surface-raised)] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]", |
| 19 | 19 | "[html[data-theme-id]_&]:[--chat-composer-glass-surface:var(--app-theme-surface-raised)] [html[data-theme-id]_&]:[--chat-composer-outline:var(--app-theme-toolbar-border)]", |
apps/web/src/components/chat/ContextWindowMeter.tsx+2| ... | ... | @@ -3,6 +3,7 @@ import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/con |
| 3 | 3 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 4 | 4 | import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; |
| 5 | 5 | import { Minimize2Icon } from "lucide-react"; |
| 6 | import { composerFloatingLayerProps } from "./composerEventScope"; | |
| 6 | 7 | |
| 7 | 8 | function formatPercentage(value: number | null): string | null { |
| 8 | 9 | if (value === null || !Number.isFinite(value)) { |
| ... | ... | @@ -83,6 +84,7 @@ export function ContextWindowMeter(props: { |
| 83 | 84 | } |
| 84 | 85 | /> |
| 85 | 86 | <PopoverPopup |
| 87 | {...composerFloatingLayerProps} | |
| 86 | 88 | tooltipStyle |
| 87 | 89 | side="top" |
| 88 | 90 | align="end" |
apps/web/src/components/chat/MessagesTimeline.test.tsx+45-8| ... | ... | @@ -3,7 +3,7 @@ import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; |
| 3 | 3 | import { createRef, type ReactNode, type Ref } from "react"; |
| 4 | 4 | import { renderToStaticMarkup } from "react-dom/server"; |
| 5 | 5 | import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | import type { LegendListRef } from "@legendapp/list/react"; | |
| 6 | import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react"; | |
| 7 | 7 | |
| 8 | 8 | vi.mock("@legendapp/list/react", async () => { |
| 9 | 9 | const legendListTestId = "legend-list"; |
| ... | ... | @@ -16,23 +16,34 @@ vi.mock("@legendapp/list/react", async () => { |
| 16 | 16 | ListFooterComponent?: ReactNode; |
| 17 | 17 | anchoredEndSpace?: { |
| 18 | 18 | anchorIndex: number; |
| 19 | anchorMaxSize?: number; | |
| 20 | anchorOffset?: number; | |
| 21 | onReady?: (info: { anchorIndex: number }) => void; | |
| 19 | 22 | }; |
| 20 | maintainScrollAtEnd?: | |
| 23 | contentInsetEndAdjustment?: number; | |
| 24 | className?: string; | |
| 25 | maintainScrollAtEnd?: boolean | MaintainScrollAtEndOptions; | |
| 26 | maintainVisibleContentPosition?: | |
| 21 | 27 | | boolean |
| 22 | 28 | | { |
| 23 | animated?: boolean; | |
| 24 | on?: { | |
| 25 | dataChange?: boolean; | |
| 26 | itemLayout?: boolean; | |
| 27 | layout?: boolean; | |
| 28 | }; | |
| 29 | data?: boolean; | |
| 30 | size?: boolean; | |
| 31 | shouldRestorePosition?: (item: { id: string }) => boolean; | |
| 29 | 32 | }; |
| 30 | 33 | ref?: Ref<LegendListRef>; |
| 31 | 34 | }) => { |
| 35 | if (props.anchoredEndSpace) { | |
| 36 | props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); | |
| 37 | } | |
| 32 | 38 | return ( |
| 33 | 39 | <div |
| 34 | 40 | data-testid={legendListTestId} |
| 35 | 41 | data-anchor-index={props.anchoredEndSpace?.anchorIndex} |
| 42 | data-anchor-max-size={props.anchoredEndSpace?.anchorMaxSize} | |
| 43 | data-anchor-offset={props.anchoredEndSpace?.anchorOffset} | |
| 44 | data-anchor-on-ready={Boolean(props.anchoredEndSpace?.onReady)} | |
| 45 | data-content-inset-end={props.contentInsetEndAdjustment} | |
| 46 | data-class-name={props.className} | |
| 36 | 47 | data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "enabled" : undefined} |
| 37 | 48 | data-maintain-scroll-at-end-animated={ |
| 38 | 49 | typeof props.maintainScrollAtEnd === "object" |
| ... | ... | @@ -44,6 +55,11 @@ vi.mock("@legendapp/list/react", async () => { |
| 44 | 55 | ? props.maintainScrollAtEnd.on?.dataChange |
| 45 | 56 | : undefined |
| 46 | 57 | } |
| 58 | data-maintain-scroll-at-end-footer-layout={ | |
| 59 | typeof props.maintainScrollAtEnd === "object" | |
| 60 | ? props.maintainScrollAtEnd.on?.footerLayout | |
| 61 | : undefined | |
| 62 | } | |
| 47 | 63 | data-maintain-scroll-at-end-item-layout={ |
| 48 | 64 | typeof props.maintainScrollAtEnd === "object" |
| 49 | 65 | ? props.maintainScrollAtEnd.on?.itemLayout |
| ... | ... | @@ -54,6 +70,26 @@ vi.mock("@legendapp/list/react", async () => { |
| 54 | 70 | ? props.maintainScrollAtEnd.on?.layout |
| 55 | 71 | : undefined |
| 56 | 72 | } |
| 73 | data-maintain-visible-content-position={ | |
| 74 | typeof props.maintainVisibleContentPosition === "object" | |
| 75 | ? "object" | |
| 76 | : props.maintainVisibleContentPosition | |
| 77 | } | |
| 78 | data-maintain-visible-content-position-data={ | |
| 79 | typeof props.maintainVisibleContentPosition === "object" | |
| 80 | ? props.maintainVisibleContentPosition.data | |
| 81 | : undefined | |
| 82 | } | |
| 83 | data-maintain-visible-content-position-size={ | |
| 84 | typeof props.maintainVisibleContentPosition === "object" | |
| 85 | ? props.maintainVisibleContentPosition.size | |
| 86 | : undefined | |
| 87 | } | |
| 88 | data-maintain-visible-content-position-restore={ | |
| 89 | typeof props.maintainVisibleContentPosition === "object" | |
| 90 | ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition) | |
| 91 | : undefined | |
| 92 | } | |
| 57 | 93 | > |
| 58 | 94 | {props.ListHeaderComponent} |
| 59 | 95 | {props.data.map((item) => ( |
| ... | ... | @@ -704,6 +740,7 @@ describe("MessagesTimeline", () => { |
| 704 | 740 | expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); |
| 705 | 741 | expect(markup).toContain('data-maintain-scroll-at-end-animated="false"'); |
| 706 | 742 | expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"'); |
| 743 | expect(markup).toContain('data-maintain-scroll-at-end-footer-layout="false"'); | |
| 707 | 744 | expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"'); |
| 708 | 745 | expect(markup).toContain('data-maintain-scroll-at-end-layout="true"'); |
| 709 | 746 | expect(markup).toContain('data-user-message-collapsed="true"'); |
apps/web/src/components/chat/MessagesTimeline.tsx+26-20| ... | ... | @@ -44,7 +44,11 @@ import { |
| 44 | 44 | type MouseEvent, |
| 45 | 45 | type ReactNode, |
| 46 | 46 | } from "react"; |
| 47 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | |
| 47 | import { | |
| 48 | LegendList, | |
| 49 | type LegendListRef, | |
| 50 | type MaintainScrollAtEndOptions, | |
| 51 | } from "@legendapp/list/react"; | |
| 48 | 52 | import { FileDiff } from "@pierre/diffs/react"; |
| 49 | 53 | import { |
| 50 | 54 | deriveTimelineEntries, |
| ... | ... | @@ -103,10 +107,7 @@ import { |
| 103 | 107 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 104 | 108 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 105 | 109 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; |
| 106 | import { | |
| 107 | CHAT_TIMELINE_ANCHOR_OFFSET, | |
| 108 | keepTimelineEndVisibleAfterOverlayGrowth, | |
| 109 | } from "./timelineScrollAnchoring"; | |
| 110 | import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring"; | |
| 110 | 111 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 111 | 112 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 112 | 113 | import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; |
| ... | ... | @@ -255,16 +256,27 @@ function TimelineLoadEarlierHeader({ |
| 255 | 256 | </div> |
| 256 | 257 | ); |
| 257 | 258 | } |
| 258 | const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; | |
| 259 | function TimelineListFooter({ composerInset }: { readonly composerInset: number }) { | |
| 260 | return ( | |
| 261 | <div aria-hidden> | |
| 262 | <div style={{ height: composerInset }} /> | |
| 263 | <div className="h-3 sm:h-4" /> | |
| 264 | </div> | |
| 265 | ); | |
| 266 | } | |
| 259 | 267 | const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| 260 | 268 | const TIMELINE_MAINTAIN_SCROLL_AT_END = { |
| 261 | 269 | animated: false, |
| 262 | 270 | on: { |
| 263 | 271 | dataChange: true, |
| 272 | // Composer inset changes must not move already-visible messages. New | |
| 273 | // rows and row growth still keep live-follow pinned through the other | |
| 274 | // triggers below. | |
| 275 | footerLayout: false, | |
| 264 | 276 | itemLayout: true, |
| 265 | 277 | layout: true, |
| 266 | 278 | }, |
| 267 | } as const; | |
| 279 | } as const satisfies MaintainScrollAtEndOptions; | |
| 268 | 280 | |
| 269 | 281 | // --------------------------------------------------------------------------- |
| 270 | 282 | // Props (public API) |
| ... | ... | @@ -381,8 +393,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 381 | 393 | const disclosureAnchorKeyRef = useRef<string | null>(null); |
| 382 | 394 | const disclosureSettleFrameRef = useRef<number | null>(null); |
| 383 | 395 | const disclosureSettleSecondFrameRef = useRef<number | null>(null); |
| 384 | const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment); | |
| 385 | ||
| 386 | 396 | useEffect(() => { |
| 387 | 397 | return () => { |
| 388 | 398 | if (disclosureSettleFrameRef.current !== null) { |
| ... | ... | @@ -533,15 +543,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 533 | 543 | onExpandTurn: expandCitedTurn, |
| 534 | 544 | onManualNavigation, |
| 535 | 545 | }); |
| 536 | useLayoutEffect(() => { | |
| 537 | keepTimelineEndVisibleAfterOverlayGrowth({ | |
| 538 | timeline: listRef.current, | |
| 539 | previousOverlayHeight: previousContentInsetEndAdjustmentRef.current, | |
| 540 | overlayHeight: contentInsetEndAdjustment, | |
| 541 | followingEnd: liveFollowEnabled && anchorMessageId === null && !citationPositioning, | |
| 542 | }); | |
| 543 | previousContentInsetEndAdjustmentRef.current = contentInsetEndAdjustment; | |
| 544 | }, [anchorMessageId, citationPositioning, contentInsetEndAdjustment, listRef, liveFollowEnabled]); | |
| 545 | 546 | const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); |
| 546 | 547 | const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); |
| 547 | 548 | const handleAnchorReady = useCallback( |
| ... | ... | @@ -561,6 +562,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 561 | 562 | ); |
| 562 | 563 | return config ? { ...config, onReady: handleAnchorReady } : undefined; |
| 563 | 564 | }, [anchorMessageId, handleAnchorReady, rows]); |
| 565 | const timelineListFooter = useMemo( | |
| 566 | () => <TimelineListFooter composerInset={anchoredEndSpace ? 0 : contentInsetEndAdjustment} />, | |
| 567 | [anchoredEndSpace, contentInsetEndAdjustment], | |
| 568 | ); | |
| 564 | 569 | |
| 565 | 570 | const handleScroll = useCallback(() => { |
| 566 | 571 | const state = listRef.current?.getState?.(); |
| ... | ... | @@ -736,7 +741,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 736 | 741 | {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})} |
| 737 | 742 | onLoad={onCitationListLoad} |
| 738 | 743 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 739 | contentInsetEndAdjustment={contentInsetEndAdjustment} | |
| 744 | contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} | |
| 740 | 745 | maintainScrollAtEnd={ |
| 741 | 746 | citationPositioning || |
| 742 | 747 | anchoredEndSpace || |
| ... | ... | @@ -748,6 +753,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 748 | 753 | maintainVisibleContentPosition={ |
| 749 | 754 | citationPositioning ? false : maintainVisibleContentPosition |
| 750 | 755 | } |
| 756 | maintainScrollAtEndThreshold={1} | |
| 751 | 757 | onScroll={handleScroll} |
| 752 | 758 | className={cn( |
| 753 | 759 | "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", |
| ... | ... | @@ -766,7 +772,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 766 | 772 | TIMELINE_LIST_HEADER |
| 767 | 773 | ) |
| 768 | 774 | } |
| 769 | ListFooterComponent={TIMELINE_LIST_FOOTER} | |
| 775 | ListFooterComponent={timelineListFooter} | |
| 770 | 776 | /> |
| 771 | 777 | <TimelineMinimap |
| 772 | 778 | items={minimapItems} |
apps/web/src/components/chat/ProviderModelPicker.test.tsx+20| ... | ... | @@ -99,4 +99,24 @@ describe("ProviderModelPicker", () => { |
| 99 | 99 | expect(markup).toContain("Fallback model"); |
| 100 | 100 | expect(markup).not.toContain(">missing-model<"); |
| 101 | 101 | }); |
| 102 | ||
| 103 | it("keeps instance initials visible in the resting trigger", () => { | |
| 104 | const activeEntry = providerEntry("codex_personal", "codex"); | |
| 105 | const markup = renderToStaticMarkup( | |
| 106 | <ProviderModelPicker | |
| 107 | activeInstanceId={activeEntry.instanceId} | |
| 108 | model="gpt-5" | |
| 109 | lockedProvider={null} | |
| 110 | instanceEntries={[providerEntry("codex", "codex"), activeEntry]} | |
| 111 | modelOptionsByInstance={new Map()} | |
| 112 | size="xs" | |
| 113 | onInstanceModelChange={() => {}} | |
| 114 | />, | |
| 115 | ); | |
| 116 | ||
| 117 | expect(markup).toContain(">CP</span>"); | |
| 118 | expect(markup).toContain("size-4"); | |
| 119 | expect(markup).toContain("h-3"); | |
| 120 | expect(markup).toContain("text-[7px]"); | |
| 121 | }); | |
| 102 | 122 | }); |
apps/web/src/components/chat/ProviderModelPicker.tsx+28-8| ... | ... | @@ -18,7 +18,12 @@ import { |
| 18 | 18 | getTriggerDisplayModelName, |
| 19 | 19 | } from "./providerIconUtils"; |
| 20 | 20 | import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; |
| 21 | import { ComposerControl, ComposerControlChevron } from "./ComposerControl"; | |
| 21 | import { | |
| 22 | ComposerControl, | |
| 23 | ComposerControlChevron, | |
| 24 | type ComposerControlSize, | |
| 25 | } from "./ComposerControl"; | |
| 26 | import { composerFloatingLayerProps } from "./composerEventScope"; | |
| 22 | 27 | |
| 23 | 28 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 24 | 29 | /** |
| ... | ... | @@ -34,7 +39,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 34 | 39 | keybindings?: ResolvedKeybindingsConfig; |
| 35 | 40 | modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>; |
| 36 | 41 | activeProviderIconClassName?: string; |
| 42 | instanceIndicatorBackground?: string; | |
| 43 | size?: ComposerControlSize; | |
| 37 | 44 | compact?: boolean; |
| 45 | isComposerOwned?: boolean; | |
| 38 | 46 | disabled?: boolean; |
| 39 | 47 | terminalOpen?: boolean; |
| 40 | 48 | open?: boolean; |
| ... | ... | @@ -47,6 +55,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 47 | 55 | }) { |
| 48 | 56 | const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); |
| 49 | 57 | const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; |
| 58 | const size = props.size ?? "sm"; | |
| 50 | 59 | |
| 51 | 60 | // Resolve the active instance entry by exact routing key. The composer |
| 52 | 61 | // resolves fallbacks before rendering this component; if the selected |
| ... | ... | @@ -148,6 +157,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 148 | 157 | <ComposerControl |
| 149 | 158 | aria-label={props.triggerAriaLabel} |
| 150 | 159 | variant={props.triggerVariant ?? "ghost"} |
| 160 | size={size} | |
| 151 | 161 | data-chat-provider-model-picker="true" |
| 152 | 162 | className={cn( |
| 153 | 163 | "min-w-0 justify-between whitespace-nowrap", |
| ... | ... | @@ -158,7 +168,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 158 | 168 | /> |
| 159 | 169 | } |
| 160 | 170 | > |
| 161 | <span className="flex min-w-0 flex-1 items-center gap-1.5"> | |
| 171 | <span | |
| 172 | className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")} | |
| 173 | > | |
| 162 | 174 | {activeEntry ? ( |
| 163 | 175 | <ProviderInstanceIcon |
| 164 | 176 | driverKind={activeEntry.driverKind} |
| ... | ... | @@ -167,15 +179,22 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 167 | 179 | showBadge={showInstanceBadge} |
| 168 | 180 | className="size-4" |
| 169 | 181 | iconClassName={cn("size-4", props.activeProviderIconClassName)} |
| 170 | indicatorBackground="var(--contrast-input)" | |
| 182 | indicatorBackground={props.instanceIndicatorBackground ?? "var(--contrast-input)"} | |
| 171 | 183 | badgeClassName={cn( |
| 172 | "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3", | |
| 173 | "px-0.5 text-[7px]", | |
| 184 | "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-[7px]", | |
| 185 | size === "xs" && "shadow-none", | |
| 174 | 186 | )} |
| 175 | 187 | /> |
| 176 | 188 | ) : null} |
| 177 | 189 | <Tooltip> |
| 178 | <TooltipTrigger render={<span className="min-w-0 flex-1 overflow-hidden truncate" />}> | |
| 190 | <TooltipTrigger | |
| 191 | render={ | |
| 192 | <span | |
| 193 | className="min-w-0 flex-1 overflow-hidden truncate" | |
| 194 | data-chat-provider-model-picker-label="true" | |
| 195 | /> | |
| 196 | } | |
| 197 | > | |
| 179 | 198 | {triggerTitle} |
| 180 | 199 | </TooltipTrigger> |
| 181 | 200 | <TooltipPopup side="top">{triggerLabel}</TooltipPopup> |
| ... | ... | @@ -187,13 +206,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 187 | 206 | ) : null} |
| 188 | 207 | </span> |
| 189 | 208 | <span aria-hidden="true" className="flex items-center"> |
| 190 | <ComposerControlChevron /> | |
| 209 | <ComposerControlChevron size={size} /> | |
| 191 | 210 | </span> |
| 192 | 211 | </PopoverTrigger> |
| 193 | 212 | <PopoverPopup |
| 213 | {...(props.isComposerOwned ? composerFloatingLayerProps : {})} | |
| 194 | 214 | align="start" |
| 195 | 215 | className="before:hidden [--viewport-inline-padding:0]" |
| 196 | viewportClassName="!overflow-hidden rounded-[calc(var(--radius-lg)-1px)] p-0 [clip-path:inset(0_round_calc(var(--radius-lg)-1px))]" | |
| 216 | viewportClassName="overflow-hidden! rounded-[calc(var(--radius-lg)-1px)] p-0 [clip-path:inset(0_round_calc(var(--radius-lg)-1px))]" | |
| 197 | 217 | > |
| 198 | 218 | <ModelPickerContent |
| 199 | 219 | activeInstanceId={activeInstanceId} |
apps/web/src/components/chat/TraitsPicker.tsx+25-6| ... | ... | @@ -32,7 +32,13 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; |
| 32 | 32 | import { getProviderModelCapabilities } from "../../providerModels"; |
| 33 | 33 | import { cn } from "~/lib/utils"; |
| 34 | 34 | import { Badge } from "../ui/badge"; |
| 35 | import { ComposerControl, ComposerControlChevron, ComposerControlIcon } from "./ComposerControl"; | |
| 35 | import { | |
| 36 | ComposerControl, | |
| 37 | ComposerControlChevron, | |
| 38 | ComposerControlIcon, | |
| 39 | type ComposerControlSize, | |
| 40 | } from "./ComposerControl"; | |
| 41 | import { composerFloatingLayerProps } from "./composerEventScope"; | |
| 36 | 42 | |
| 37 | 43 | type ProviderOptions = ReadonlyArray<ProviderOptionSelection>; |
| 38 | 44 | |
| ... | ... | @@ -274,6 +280,7 @@ export interface TraitsMenuContentProps { |
| 274 | 280 | planModeEnabled: boolean; |
| 275 | 281 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; |
| 276 | 282 | triggerClassName?: string; |
| 283 | isComposerOwned?: boolean; | |
| 277 | 284 | } |
| 278 | 285 | |
| 279 | 286 | export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| ... | ... | @@ -540,8 +547,13 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 540 | 547 | planModeEnabled, |
| 541 | 548 | triggerVariant, |
| 542 | 549 | triggerClassName, |
| 550 | isComposerOwned, | |
| 551 | size = "sm", | |
| 543 | 552 | ...persistence |
| 544 | }: TraitsMenuContentProps & TraitsPersistence) { | |
| 553 | }: TraitsMenuContentProps & | |
| 554 | TraitsPersistence & { | |
| 555 | size?: ComposerControlSize; | |
| 556 | }) { | |
| 545 | 557 | const [isMenuOpen, setIsMenuOpen] = useState(false); |
| 546 | 558 | const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = |
| 547 | 559 | getTraitsSectionVisibility({ |
| ... | ... | @@ -577,6 +589,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 577 | 589 | <> |
| 578 | 590 | <ComposerControlIcon |
| 579 | 591 | icon={ZapIcon} |
| 592 | size={size} | |
| 580 | 593 | className={cn( |
| 581 | 594 | "fill-current opacity-80", |
| 582 | 595 | provider === "claudeAgent" ? "text-[#d97757]" : "text-foreground", |
| ... | ... | @@ -599,6 +612,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 599 | 612 | render={ |
| 600 | 613 | <ComposerControl |
| 601 | 614 | variant={triggerVariant ?? "ghost"} |
| 615 | size={size} | |
| 602 | 616 | className={cn( |
| 603 | 617 | isCodexStyle |
| 604 | 618 | ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap sm:max-w-48" |
| ... | ... | @@ -609,20 +623,25 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 609 | 623 | } |
| 610 | 624 | > |
| 611 | 625 | {isCodexStyle ? ( |
| 612 | <span className="flex min-w-0 w-full items-center gap-1.5 overflow-hidden"> | |
| 626 | <span | |
| 627 | className={cn( | |
| 628 | "flex min-w-0 w-full items-center overflow-hidden", | |
| 629 | size === "xs" ? "gap-1" : "gap-1.5", | |
| 630 | )} | |
| 631 | > | |
| 613 | 632 | {fastModeIcon} |
| 614 | 633 | <span className="min-w-0 truncate">{triggerLabel}</span> |
| 615 | <ComposerControlChevron /> | |
| 634 | <ComposerControlChevron size={size} /> | |
| 616 | 635 | </span> |
| 617 | 636 | ) : ( |
| 618 | 637 | <> |
| 619 | 638 | {fastModeIcon} |
| 620 | 639 | <span>{triggerLabel}</span> |
| 621 | <ComposerControlChevron /> | |
| 640 | <ComposerControlChevron size={size} /> | |
| 622 | 641 | </> |
| 623 | 642 | )} |
| 624 | 643 | </MenuTrigger> |
| 625 | <MenuPopup align="start"> | |
| 644 | <MenuPopup align="start" {...(isComposerOwned ? composerFloatingLayerProps : {})}> | |
| 626 | 645 | <TraitsMenuContent |
| 627 | 646 | provider={provider} |
| 628 | 647 | {...(instanceId ? { instanceId } : {})} |
apps/web/src/components/chat/composerEventScope.test.ts created+61| ... | ... | @@ -0,0 +1,61 @@ |
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | isInsideComposerFloatingLayer, | |
| 5 | isInsideRestingComposerControlScope, | |
| 6 | } from "./composerEventScope"; | |
| 7 | ||
| 8 | class FakeElement { | |
| 9 | constructor(private readonly matchingSelector: string | null) {} | |
| 10 | ||
| 11 | closest(selector: string): FakeElement | null { | |
| 12 | return this.matchingSelector !== null && | |
| 13 | selector.split(",").some((candidate) => candidate === this.matchingSelector) | |
| 14 | ? this | |
| 15 | : null; | |
| 16 | } | |
| 17 | } | |
| 18 | ||
| 19 | afterEach(() => { | |
| 20 | vi.unstubAllGlobals(); | |
| 21 | }); | |
| 22 | ||
| 23 | describe("composer event scopes", () => { | |
| 24 | it("recognizes events from the portaled resting controls", () => { | |
| 25 | vi.stubGlobal("Element", FakeElement); | |
| 26 | ||
| 27 | const target = new FakeElement('[data-chat-composer-resting-controls="true"]'); | |
| 28 | expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); | |
| 29 | }); | |
| 30 | ||
| 31 | it("keeps resting image previews focused without expanding their subtree", () => { | |
| 32 | vi.stubGlobal("Element", FakeElement); | |
| 33 | ||
| 34 | const target = new FakeElement('[data-chat-composer-resting-images="true"]'); | |
| 35 | expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); | |
| 36 | }); | |
| 37 | ||
| 38 | it("includes composer-owned floating layers in the resting control scope", () => { | |
| 39 | vi.stubGlobal("Element", FakeElement); | |
| 40 | ||
| 41 | const target = new FakeElement('[data-chat-composer-floating-layer="true"]'); | |
| 42 | expect(isInsideComposerFloatingLayer(target as unknown as EventTarget)).toBe(true); | |
| 43 | expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); | |
| 44 | }); | |
| 45 | ||
| 46 | it("leaves unrelated floating layers outside the composer scope", () => { | |
| 47 | vi.stubGlobal("Element", FakeElement); | |
| 48 | ||
| 49 | const target = new FakeElement('[data-slot="popover-popup"]'); | |
| 50 | expect(isInsideComposerFloatingLayer(target as unknown as EventTarget)).toBe(false); | |
| 51 | expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(false); | |
| 52 | }); | |
| 53 | ||
| 54 | it("leaves ordinary composer targets outside the portaled control scope", () => { | |
| 55 | vi.stubGlobal("Element", FakeElement); | |
| 56 | ||
| 57 | const target = new FakeElement(null); | |
| 58 | expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(false); | |
| 59 | expect(isInsideRestingComposerControlScope(null)).toBe(false); | |
| 60 | }); | |
| 61 | }); |
apps/web/src/components/chat/composerEventScope.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | const COMPOSER_FLOATING_LAYER_SELECTOR = [ | |
| 2 | '[data-composer-drawer-layer="true"]', | |
| 3 | '[data-chat-composer-floating-layer="true"]', | |
| 4 | ].join(","); | |
| 5 | ||
| 6 | export const composerFloatingLayerProps = { | |
| 7 | "data-chat-composer-floating-layer": "true", | |
| 8 | } as const; | |
| 9 | ||
| 10 | export function isInsideComposerFloatingLayer(target: EventTarget | null): boolean { | |
| 11 | return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; | |
| 12 | } | |
| 13 | ||
| 14 | export function isInsideRestingComposerControlScope(target: EventTarget | null): boolean { | |
| 15 | return ( | |
| 16 | target instanceof Element && | |
| 17 | (target.closest('[data-chat-composer-resting-controls="true"]') !== null || | |
| 18 | target.closest('[data-chat-composer-resting-images="true"]') !== null || | |
| 19 | isInsideComposerFloatingLayer(target)) | |
| 20 | ); | |
| 21 | } |
apps/web/src/components/chat/composerProviderState.tsx+15| ... | ... | @@ -12,10 +12,13 @@ import { |
| 12 | 12 | isClaudeUltrathinkPrompt, |
| 13 | 13 | normalizeModelSlug, |
| 14 | 14 | } from "@t3tools/shared/model"; |
| 15 | import type { VariantProps } from "class-variance-authority"; | |
| 15 | 16 | import type { ReactNode } from "react"; |
| 16 | 17 | |
| 18 | import type { buttonVariants } from "../ui/button"; | |
| 17 | 19 | import type { DraftId } from "../../composerDraftStore"; |
| 18 | 20 | import { getProviderModelCapabilities } from "../../providerModels"; |
| 21 | import type { ComposerControlSize } from "./ComposerControl"; | |
| 19 | 22 | import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; |
| 20 | 23 | |
| 21 | 24 | export type ComposerProviderStateInput = { |
| ... | ... | @@ -49,6 +52,10 @@ type TraitsRenderInput = { |
| 49 | 52 | prompt: string; |
| 50 | 53 | onPromptChange: (prompt: string) => void; |
| 51 | 54 | planModeEnabled: boolean; |
| 55 | size?: ComposerControlSize; | |
| 56 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; | |
| 57 | triggerClassName?: string; | |
| 58 | isComposerOwned?: boolean; | |
| 52 | 59 | }; |
| 53 | 60 | |
| 54 | 61 | export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { |
| ... | ... | @@ -123,6 +130,10 @@ function renderTraitsControl( |
| 123 | 130 | prompt, |
| 124 | 131 | onPromptChange, |
| 125 | 132 | planModeEnabled, |
| 133 | size, | |
| 134 | triggerVariant, | |
| 135 | triggerClassName, | |
| 136 | isComposerOwned, | |
| 126 | 137 | } = input; |
| 127 | 138 | const hasTarget = threadRef !== undefined || draftId !== undefined; |
| 128 | 139 | if ( |
| ... | ... | @@ -150,6 +161,10 @@ function renderTraitsControl( |
| 150 | 161 | prompt={prompt} |
| 151 | 162 | onPromptChange={onPromptChange} |
| 152 | 163 | planModeEnabled={planModeEnabled} |
| 164 | {...(size !== undefined ? { size } : {})} | |
| 165 | {...(triggerVariant !== undefined ? { triggerVariant } : {})} | |
| 166 | {...(triggerClassName !== undefined ? { triggerClassName } : {})} | |
| 167 | {...(isComposerOwned ? { isComposerOwned } : {})} | |
| 153 | 168 | /> |
| 154 | 169 | ); |
| 155 | 170 | } |
apps/web/src/components/chat/composerScrollGesture.test.ts created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | createComposerScrollGestureState, | |
| 5 | recordComposerScrollGestureEvent, | |
| 6 | resetComposerScrollGesture, | |
| 7 | suppressActiveComposerScrollGesture, | |
| 8 | } from "./composerScrollGesture"; | |
| 9 | ||
| 10 | const RESET_MS = 120; | |
| 11 | const THRESHOLD_PX = 24; | |
| 12 | ||
| 13 | function record( | |
| 14 | state: ReturnType<typeof createComposerScrollGestureState>, | |
| 15 | now: number, | |
| 16 | options: Partial<{ | |
| 17 | deltaPx: number; | |
| 18 | collapseEligible: boolean; | |
| 19 | canScrollInGestureDirection: boolean; | |
| 20 | scrollsTowardLogicalEnd: boolean; | |
| 21 | }> = {}, | |
| 22 | ) { | |
| 23 | return recordComposerScrollGestureEvent(state, { | |
| 24 | now, | |
| 25 | deltaPx: options.deltaPx ?? 30, | |
| 26 | collapseThresholdPx: THRESHOLD_PX, | |
| 27 | collapseEligible: options.collapseEligible ?? true, | |
| 28 | canScrollInGestureDirection: options.canScrollInGestureDirection ?? true, | |
| 29 | scrollsTowardLogicalEnd: options.scrollsTowardLogicalEnd ?? false, | |
| 30 | }); | |
| 31 | } | |
| 32 | ||
| 33 | describe("composer scroll gesture", () => { | |
| 34 | it("lets an editor change win over the rest of the active gesture", () => { | |
| 35 | const state = createComposerScrollGestureState(); | |
| 36 | ||
| 37 | expect(record(state, 0)).toBe(true); | |
| 38 | suppressActiveComposerScrollGesture(state, 20, RESET_MS); | |
| 39 | ||
| 40 | expect(record(state, 40)).toBe(false); | |
| 41 | expect(record(state, 80)).toBe(false); | |
| 42 | expect(record(state, 160)).toBe(false); | |
| 43 | }); | |
| 44 | ||
| 45 | it("preserves suppression while collapse is temporarily ineligible", () => { | |
| 46 | const state = createComposerScrollGestureState(); | |
| 47 | ||
| 48 | record(state, 0); | |
| 49 | suppressActiveComposerScrollGesture(state, 20, RESET_MS); | |
| 50 | ||
| 51 | expect(record(state, 40, { collapseEligible: false })).toBe(false); | |
| 52 | expect(record(state, 80, { collapseEligible: true })).toBe(false); | |
| 53 | }); | |
| 54 | ||
| 55 | it("keeps a boundary momentum tail in the same suppressed gesture", () => { | |
| 56 | const state = createComposerScrollGestureState(); | |
| 57 | ||
| 58 | record(state, 0); | |
| 59 | suppressActiveComposerScrollGesture(state, 20, RESET_MS); | |
| 60 | ||
| 61 | expect(record(state, 80, { canScrollInGestureDirection: false })).toBe(false); | |
| 62 | expect(state.lastEventAt).toBe(80); | |
| 63 | expect(record(state, 160)).toBe(false); | |
| 64 | }); | |
| 65 | ||
| 66 | it("does not carry gesture state into a new thread after reset", () => { | |
| 67 | const state = createComposerScrollGestureState(); | |
| 68 | ||
| 69 | record(state, 0); | |
| 70 | suppressActiveComposerScrollGesture(state, 20, RESET_MS); | |
| 71 | record(state, 80); | |
| 72 | resetComposerScrollGesture(state); | |
| 73 | ||
| 74 | expect(record(state, 240)).toBe(true); | |
| 75 | }); | |
| 76 | ||
| 77 | it("accumulates small deltas only while collapse remains eligible and scrollable", () => { | |
| 78 | const state = createComposerScrollGestureState(); | |
| 79 | ||
| 80 | expect(record(state, 0, { deltaPx: 10 })).toBe(false); | |
| 81 | expect(record(state, 20, { deltaPx: 10 })).toBe(false); | |
| 82 | expect(record(state, 40, { deltaPx: 4 })).toBe(true); | |
| 83 | expect(record(state, 60, { deltaPx: 10, collapseEligible: false })).toBe(false); | |
| 84 | expect(record(state, 80, { deltaPx: 14 })).toBe(false); | |
| 85 | }); | |
| 86 | ||
| 87 | it("does not collapse while scrolling down through composer footer space", () => { | |
| 88 | const state = createComposerScrollGestureState(); | |
| 89 | ||
| 90 | expect(record(state, 0, { deltaPx: 20 })).toBe(false); | |
| 91 | expect(record(state, 20, { scrollsTowardLogicalEnd: true })).toBe(false); | |
| 92 | expect(record(state, 40, { deltaPx: 10 })).toBe(false); | |
| 93 | }); | |
| 94 | }); |
apps/web/src/components/chat/composerScrollGesture.ts created+60| ... | ... | @@ -0,0 +1,60 @@ |
| 1 | export type ComposerScrollGestureState = { | |
| 2 | accumulatedDeltaPx: number; | |
| 3 | collapseSuppressed: boolean; | |
| 4 | lastEventAt: number; | |
| 5 | }; | |
| 6 | ||
| 7 | export function createComposerScrollGestureState(): ComposerScrollGestureState { | |
| 8 | return { | |
| 9 | accumulatedDeltaPx: 0, | |
| 10 | collapseSuppressed: false, | |
| 11 | lastEventAt: Number.NEGATIVE_INFINITY, | |
| 12 | }; | |
| 13 | } | |
| 14 | ||
| 15 | export function resetComposerScrollGesture(state: ComposerScrollGestureState): void { | |
| 16 | state.accumulatedDeltaPx = 0; | |
| 17 | state.collapseSuppressed = false; | |
| 18 | state.lastEventAt = Number.NEGATIVE_INFINITY; | |
| 19 | } | |
| 20 | ||
| 21 | export function suppressActiveComposerScrollGesture( | |
| 22 | state: ComposerScrollGestureState, | |
| 23 | now: number, | |
| 24 | gestureResetMs: number, | |
| 25 | ): void { | |
| 26 | if (now - state.lastEventAt <= gestureResetMs) { | |
| 27 | state.collapseSuppressed = true; | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | export function recordComposerScrollGestureEvent( | |
| 32 | state: ComposerScrollGestureState, | |
| 33 | input: { | |
| 34 | now: number; | |
| 35 | deltaPx: number; | |
| 36 | collapseThresholdPx: number; | |
| 37 | collapseEligible: boolean; | |
| 38 | canScrollInGestureDirection: boolean; | |
| 39 | scrollsTowardLogicalEnd: boolean; | |
| 40 | }, | |
| 41 | ): boolean { | |
| 42 | state.lastEventAt = input.now; | |
| 43 | if ( | |
| 44 | state.collapseSuppressed || | |
| 45 | !input.collapseEligible || | |
| 46 | !input.canScrollInGestureDirection || | |
| 47 | input.scrollsTowardLogicalEnd | |
| 48 | ) { | |
| 49 | state.accumulatedDeltaPx = 0; | |
| 50 | return false; | |
| 51 | } | |
| 52 | ||
| 53 | state.accumulatedDeltaPx += input.deltaPx; | |
| 54 | if (state.accumulatedDeltaPx < input.collapseThresholdPx) { | |
| 55 | return false; | |
| 56 | } | |
| 57 | ||
| 58 | state.accumulatedDeltaPx = 0; | |
| 59 | return true; | |
| 60 | } |
apps/web/src/components/chat/timelineScrollAnchoring.test.tsx+2-33| ... | ... | @@ -1,9 +1,5 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | import { | |
| 3 | getAnchoredTurnMetrics, | |
| 4 | getRowBottom, | |
| 5 | keepTimelineEndVisibleAfterOverlayGrowth, | |
| 6 | } from "./timelineScrollAnchoring"; | |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring"; | |
| 7 | 3 | |
| 8 | 4 | function buildState({ |
| 9 | 5 | positions, |
| ... | ... | @@ -26,33 +22,6 @@ function buildState({ |
| 26 | 22 | } |
| 27 | 23 | |
| 28 | 24 | describe("timeline scroll anchoring", () => { |
| 29 | it("keeps the live edge visible when the composer overlay grows", () => { | |
| 30 | const scrollToEnd = vi.fn(); | |
| 31 | ||
| 32 | keepTimelineEndVisibleAfterOverlayGrowth({ | |
| 33 | timeline: { scrollToEnd }, | |
| 34 | previousOverlayHeight: 120, | |
| 35 | overlayHeight: 180, | |
| 36 | followingEnd: true, | |
| 37 | }); | |
| 38 | ||
| 39 | expect(scrollToEnd).toHaveBeenCalledOnce(); | |
| 40 | expect(scrollToEnd).toHaveBeenCalledWith({ animated: false }); | |
| 41 | }); | |
| 42 | ||
| 43 | it("leaves the scroll position alone while the user reads history", () => { | |
| 44 | const scrollToEnd = vi.fn(); | |
| 45 | ||
| 46 | keepTimelineEndVisibleAfterOverlayGrowth({ | |
| 47 | timeline: { scrollToEnd }, | |
| 48 | previousOverlayHeight: 120, | |
| 49 | overlayHeight: 180, | |
| 50 | followingEnd: false, | |
| 51 | }); | |
| 52 | ||
| 53 | expect(scrollToEnd).not.toHaveBeenCalled(); | |
| 54 | }); | |
| 55 | ||
| 56 | 25 | it("measures row bottoms from LegendList row position and size", () => { |
| 57 | 26 | const state = buildState({ |
| 58 | 27 | positions: [0, 120], |
apps/web/src/components/chat/timelineScrollAnchoring.ts-16| ... | ... | @@ -22,22 +22,6 @@ export interface AnchoredTurnMetrics { |
| 22 | 22 | readonly scrollDeltaToRevealEnd: number; |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | export function keepTimelineEndVisibleAfterOverlayGrowth({ | |
| 26 | timeline, | |
| 27 | previousOverlayHeight, | |
| 28 | overlayHeight, | |
| 29 | followingEnd, | |
| 30 | }: { | |
| 31 | readonly timeline: { scrollToEnd: (options: { animated: boolean }) => unknown } | null; | |
| 32 | readonly previousOverlayHeight: number; | |
| 33 | readonly overlayHeight: number; | |
| 34 | readonly followingEnd: boolean; | |
| 35 | }): void { | |
| 36 | if (timeline && followingEnd && overlayHeight > previousOverlayHeight) { | |
| 37 | void timeline.scrollToEnd({ animated: false }); | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | 25 | export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null { |
| 42 | 26 | const top = state.positionAtIndex(index); |
| 43 | 27 | const height = state.sizeAtIndex(index); |
apps/web/src/components/composerFooterLayout.test.ts+163| ... | ... | @@ -3,10 +3,31 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | 4 | COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, |
| 5 | 5 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, |
| 6 | getRestingComposerImagePreviewCounts, | |
| 7 | resolveRestingComposerControlsLayout, | |
| 8 | shouldAnimateComposerRestingTransition, | |
| 6 | 9 | shouldUseCompactComposerPrimaryActions, |
| 7 | 10 | shouldUseCompactComposerFooter, |
| 11 | shouldUseRestingComposerLayout, | |
| 8 | 12 | } from "./composerFooterLayout"; |
| 9 | 13 | |
| 14 | describe("getRestingComposerImagePreviewCounts", () => { | |
| 15 | it("shows at most three thumbnails and counts the remainder", () => { | |
| 16 | expect(getRestingComposerImagePreviewCounts(0)).toEqual({ | |
| 17 | visibleCount: 0, | |
| 18 | overflowCount: 0, | |
| 19 | }); | |
| 20 | expect(getRestingComposerImagePreviewCounts(3)).toEqual({ | |
| 21 | visibleCount: 3, | |
| 22 | overflowCount: 0, | |
| 23 | }); | |
| 24 | expect(getRestingComposerImagePreviewCounts(7)).toEqual({ | |
| 25 | visibleCount: 3, | |
| 26 | overflowCount: 4, | |
| 27 | }); | |
| 28 | }); | |
| 29 | }); | |
| 30 | ||
| 10 | 31 | describe("shouldUseCompactComposerFooter", () => { |
| 11 | 32 | it("stays expanded without a measured width", () => { |
| 12 | 33 | expect(shouldUseCompactComposerFooter(null)).toBe(false); |
| ... | ... | @@ -50,3 +71,145 @@ describe("shouldUseCompactComposerPrimaryActions", () => { |
| 50 | 71 | ).toBe(false); |
| 51 | 72 | }); |
| 52 | 73 | }); |
| 74 | ||
| 75 | describe("shouldUseRestingComposerLayout", () => { | |
| 76 | const resting = { | |
| 77 | isExistingThread: true, | |
| 78 | isMobileViewport: false, | |
| 79 | isFocused: false, | |
| 80 | hasExpandedChrome: false, | |
| 81 | }; | |
| 82 | ||
| 83 | it("uses the resting layout for an unfocused desktop composer", () => { | |
| 84 | expect(shouldUseRestingComposerLayout(resting)).toBe(true); | |
| 85 | }); | |
| 86 | ||
| 87 | it("keeps new-thread composers expanded", () => { | |
| 88 | expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false); | |
| 89 | }); | |
| 90 | ||
| 91 | it("leaves responsive mobile on its existing collapse path", () => { | |
| 92 | expect(shouldUseRestingComposerLayout({ ...resting, isMobileViewport: true })).toBe(false); | |
| 93 | }); | |
| 94 | ||
| 95 | it("expands when focus is anywhere in the composer", () => { | |
| 96 | expect(shouldUseRestingComposerLayout({ ...resting, isFocused: true })).toBe(false); | |
| 97 | }); | |
| 98 | ||
| 99 | it("keeps drawers and composer-owned menus expanded", () => { | |
| 100 | expect(shouldUseRestingComposerLayout({ ...resting, hasExpandedChrome: true })).toBe(false); | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 104 | describe("shouldAnimateComposerRestingTransition", () => { | |
| 105 | it("does not animate layout measurements that settle during initial mount", () => { | |
| 106 | expect( | |
| 107 | shouldAnimateComposerRestingTransition({ | |
| 108 | hasCompletedInitialLayout: false, | |
| 109 | stateChanged: true, | |
| 110 | hasInterruptedAnimation: false, | |
| 111 | }), | |
| 112 | ).toBe(false); | |
| 113 | }); | |
| 114 | ||
| 115 | it("animates later resting-state changes and interrupted transitions", () => { | |
| 116 | expect( | |
| 117 | shouldAnimateComposerRestingTransition({ | |
| 118 | hasCompletedInitialLayout: true, | |
| 119 | stateChanged: true, | |
| 120 | hasInterruptedAnimation: false, | |
| 121 | }), | |
| 122 | ).toBe(true); | |
| 123 | expect( | |
| 124 | shouldAnimateComposerRestingTransition({ | |
| 125 | hasCompletedInitialLayout: true, | |
| 126 | stateChanged: false, | |
| 127 | hasInterruptedAnimation: true, | |
| 128 | }), | |
| 129 | ).toBe(true); | |
| 130 | }); | |
| 131 | }); | |
| 132 | ||
| 133 | describe("resolveRestingComposerControlsLayout", () => { | |
| 134 | // Picker 140 natural / 96 minimum, plus a 9px separator. Traits 60, | |
| 135 | // mode 140, overflow 24, gap 4. | |
| 136 | const base = { | |
| 137 | gap: 4, | |
| 138 | naturalFixedWidth: 149, | |
| 139 | minimumFixedWidth: 105, | |
| 140 | blockWidths: [60, 140], | |
| 141 | overflowWidth: 24, | |
| 142 | }; | |
| 143 | ||
| 144 | it("shows everything when the host has room", () => { | |
| 145 | // 149 + 60 + 140 + 4 * 2 = 357 | |
| 146 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({ | |
| 147 | hiddenCount: 0, | |
| 148 | visible: true, | |
| 149 | }); | |
| 150 | }); | |
| 151 | ||
| 152 | it("moves trailing blocks into the overflow menu until the rest fits", () => { | |
| 153 | // 149 + 60 + 24 + 4 * 2 = 241 | |
| 154 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 356 })).toEqual({ | |
| 155 | hiddenCount: 1, | |
| 156 | visible: true, | |
| 157 | }); | |
| 158 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241 })).toEqual({ | |
| 159 | hiddenCount: 1, | |
| 160 | visible: true, | |
| 161 | }); | |
| 162 | // 149 + 24 + 4 = 177 | |
| 163 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 240 })).toEqual({ | |
| 164 | hiddenCount: 2, | |
| 165 | visible: true, | |
| 166 | }); | |
| 167 | }); | |
| 168 | ||
| 169 | it("shrinks the picker after moving every trailing block into overflow", () => { | |
| 170 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 176 })).toEqual({ | |
| 171 | hiddenCount: 2, | |
| 172 | visible: true, | |
| 173 | }); | |
| 174 | // 105 + 24 + 4 = 133 | |
| 175 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 133 })).toEqual({ | |
| 176 | hiddenCount: 2, | |
| 177 | visible: true, | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | it("hides the whole cluster below the picker's minimum readable width", () => { | |
| 182 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 132 })).toEqual({ | |
| 183 | hiddenCount: 2, | |
| 184 | visible: false, | |
| 185 | }); | |
| 186 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 0 })).toEqual({ | |
| 187 | hiddenCount: 2, | |
| 188 | visible: false, | |
| 189 | }); | |
| 190 | }); | |
| 191 | ||
| 192 | it("uses the same thresholds while shrinking and growing", () => { | |
| 193 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 240 })).toEqual({ | |
| 194 | hiddenCount: 2, | |
| 195 | visible: true, | |
| 196 | }); | |
| 197 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241 })).toEqual({ | |
| 198 | hiddenCount: 1, | |
| 199 | visible: true, | |
| 200 | }); | |
| 201 | }); | |
| 202 | ||
| 203 | it("supports a single leading control without overflow blocks", () => { | |
| 204 | expect( | |
| 205 | resolveRestingComposerControlsLayout({ | |
| 206 | ...base, | |
| 207 | naturalFixedWidth: 140, | |
| 208 | minimumFixedWidth: 140, | |
| 209 | blockWidths: [], | |
| 210 | overflowWidth: 0, | |
| 211 | hostWidth: 139, | |
| 212 | }), | |
| 213 | ).toEqual({ hiddenCount: 0, visible: false }); | |
| 214 | }); | |
| 215 | }); |
apps/web/src/components/composerFooterLayout.ts+77| ... | ... | @@ -1,5 +1,17 @@ |
| 1 | 1 | export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; |
| 2 | 2 | export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; |
| 3 | export const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; | |
| 4 | ||
| 5 | export function getRestingComposerImagePreviewCounts(imageCount: number): { | |
| 6 | visibleCount: number; | |
| 7 | overflowCount: number; | |
| 8 | } { | |
| 9 | const visibleCount = Math.min(imageCount, RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT); | |
| 10 | return { | |
| 11 | visibleCount, | |
| 12 | overflowCount: Math.max(0, imageCount - visibleCount), | |
| 13 | }; | |
| 14 | } | |
| 3 | 15 | |
| 4 | 16 | export function shouldUseCompactComposerFooter( |
| 5 | 17 | width: number | null, |
| ... | ... | @@ -11,6 +23,36 @@ export function shouldUseCompactComposerFooter( |
| 11 | 23 | return width !== null && width < breakpoint; |
| 12 | 24 | } |
| 13 | 25 | |
| 26 | export function shouldUseRestingComposerLayout(input: { | |
| 27 | isExistingThread: boolean; | |
| 28 | isMobileViewport: boolean; | |
| 29 | isFocused: boolean; | |
| 30 | hasExpandedChrome: boolean; | |
| 31 | }): boolean { | |
| 32 | // Passive draft content is deliberately absent here. Resting only clamps | |
| 33 | // the prompt row and overlays its actions; non-image attachment and context | |
| 34 | // rows keep their natural height above it while image previews move inline. | |
| 35 | // Banners and the tasks badge dock above the surface, so they are absent | |
| 36 | // too. Whether the context strip can host the relocated controls is | |
| 37 | // deliberately absent here: resting reclaims vertical space at every | |
| 38 | // desktop width, and where the strip is missing or too narrow the controls | |
| 39 | // simply return when the composer is focused. | |
| 40 | return ( | |
| 41 | input.isExistingThread && | |
| 42 | !input.isMobileViewport && | |
| 43 | !input.isFocused && | |
| 44 | !input.hasExpandedChrome | |
| 45 | ); | |
| 46 | } | |
| 47 | ||
| 48 | export function shouldAnimateComposerRestingTransition(input: { | |
| 49 | hasCompletedInitialLayout: boolean; | |
| 50 | stateChanged: boolean; | |
| 51 | hasInterruptedAnimation: boolean; | |
| 52 | }): boolean { | |
| 53 | return input.hasCompletedInitialLayout && (input.stateChanged || input.hasInterruptedAnimation); | |
| 54 | } | |
| 55 | ||
| 14 | 56 | export function shouldUseCompactComposerPrimaryActions( |
| 15 | 57 | width: number | null, |
| 16 | 58 | options?: { hasWideActions?: boolean }, |
| ... | ... | @@ -20,3 +62,38 @@ export function shouldUseCompactComposerPrimaryActions( |
| 20 | 62 | } |
| 21 | 63 | return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX; |
| 22 | 64 | } |
| 65 | ||
| 66 | /** | |
| 67 | * Decide how many trailing resting control blocks move into the overflow | |
| 68 | * menu, and whether the cluster can show at all, from natural widths. | |
| 69 | * | |
| 70 | * Trailing blocks hide before the model picker shrinks. Once they are all in | |
| 71 | * the overflow menu, the picker may contract to its minimum readable width; | |
| 72 | * below that the whole cluster hides rather than clipping. | |
| 73 | */ | |
| 74 | export function resolveRestingComposerControlsLayout(input: { | |
| 75 | hostWidth: number; | |
| 76 | gap: number; | |
| 77 | naturalFixedWidth: number; | |
| 78 | minimumFixedWidth: number; | |
| 79 | blockWidths: readonly number[]; | |
| 80 | overflowWidth: number; | |
| 81 | }): { hiddenCount: number; visible: boolean } { | |
| 82 | const { blockWidths, gap, hostWidth } = input; | |
| 83 | const widthWithHidden = (hidden: number, fixedWidth = input.naturalFixedWidth) => { | |
| 84 | const visibleCount = blockWidths.length - hidden; | |
| 85 | return ( | |
| 86 | fixedWidth + | |
| 87 | blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) + | |
| 88 | (hidden > 0 ? input.overflowWidth : 0) + | |
| 89 | gap * (visibleCount + (hidden > 0 ? 1 : 0)) | |
| 90 | ); | |
| 91 | }; | |
| 92 | ||
| 93 | let hiddenCount = 0; | |
| 94 | while (hiddenCount < blockWidths.length && widthWithHidden(hiddenCount) > hostWidth) { | |
| 95 | hiddenCount += 1; | |
| 96 | } | |
| 97 | const visible = widthWithHidden(hiddenCount, input.minimumFixedWidth) <= hostWidth; | |
| 98 | return { hiddenCount, visible }; | |
| 99 | } |
apps/web/src/components/composerSelection.test.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { didComposerSelectionChangeVisibly } from "./composerSelection"; | |
| 4 | ||
| 5 | describe("didComposerSelectionChangeVisibly", () => { | |
| 6 | it("ignores selection-neutral editor updates", () => { | |
| 7 | expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, null)).toBe(false); | |
| 8 | expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, { start: 3, end: 3 })).toBe( | |
| 9 | false, | |
| 10 | ); | |
| 11 | }); | |
| 12 | ||
| 13 | it("detects entering and extending a visible range selection", () => { | |
| 14 | expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, { start: 0, end: 3 })).toBe( | |
| 15 | true, | |
| 16 | ); | |
| 17 | expect(didComposerSelectionChangeVisibly({ start: 1, end: 3 }, { start: 0, end: 3 })).toBe( | |
| 18 | true, | |
| 19 | ); | |
| 20 | }); | |
| 21 | ||
| 22 | it("does not repeatedly report the same visible selection", () => { | |
| 23 | expect(didComposerSelectionChangeVisibly({ start: 0, end: 3 }, { start: 0, end: 3 })).toBe( | |
| 24 | false, | |
| 25 | ); | |
| 26 | }); | |
| 27 | }); |
apps/web/src/components/composerSelection.ts created+15| ... | ... | @@ -0,0 +1,15 @@ |
| 1 | export type ComposerSelectionRange = { | |
| 2 | start: number; | |
| 3 | end: number; | |
| 4 | }; | |
| 5 | ||
| 6 | export function didComposerSelectionChangeVisibly( | |
| 7 | previous: ComposerSelectionRange, | |
| 8 | next: ComposerSelectionRange | null, | |
| 9 | ): boolean { | |
| 10 | return ( | |
| 11 | next !== null && | |
| 12 | next.start !== next.end && | |
| 13 | (next.start !== previous.start || next.end !== previous.end) | |
| 14 | ); | |
| 15 | } |
docs/user/composer.md+12| ... | ... | @@ -51,6 +51,18 @@ On web and desktop, HEIC and HEIF photos are automatically converted to JPEG whe |
| 51 | 51 | the composer or paste them into a message. On iOS, selecting them from **Photo Library** also |
| 52 | 52 | converts them to JPEG. The 10 MB image limit applies to the converted photo. |
| 53 | 53 | |
| 54 | On web and desktop, an existing thread settles its composer into a single-line resting state when | |
| 55 | the composer loses focus. At wider sizes, scrolling the conversation also rests a focused composer, | |
| 56 | except when scrolling toward the end while already there. When the thread-context strip has room, | |
| 57 | the model and mode controls stay available beside the thread context; otherwise they return when the | |
| 58 | composer is focused. Focus the composer or start typing to expand it again. New-thread layouts keep | |
| 59 | the full composer. | |
| 60 | ||
| 61 | At phone-sized web or desktop window widths, existing threads animate between their compact and | |
| 62 | expanded layouts. Up to three image attachments remain visible in either resting layout, followed | |
| 63 | by a count when more are attached. At wider sizes, videos, files, and other draft context remain | |
| 64 | visible at their natural height; the phone-sized compact row reveals those details when expanded. | |
| 65 | ||
| 54 | 66 | On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic, |
| 55 | 67 | GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list |
| 56 | 68 | when starting a thread or changing an existing thread's model. |