authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-09-03 07:53:41+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 23:53:41-07:00
log5b8445b7a777ab1070aa97b062b1618971073a96
treef38e010b51e86e0e23a0d129bf3370f7f4cc345c
parent24799de4f85a9071afc2420362784ea46041d544
signature Signed by PGP key B5690EEEBB952194

fix(web): collapse the resting composer (#7855)

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", () => {
430430 hasActiveProject: true,
431431 isGitRepo: false,
432432 showEnvironmentIndicator: true,
433 hostsRestingComposerControls: false,
433434 }),
434435 ).toBe(true);
435436 });
436437
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", () => {
438439 expect(
439440 shouldShowComposerContextStrip({
440441 hasActiveProject: true,
441442 isGitRepo: false,
442443 showEnvironmentIndicator: false,
444 hostsRestingComposerControls: false,
443445 }),
444446 ).toBe(false);
445447 });
446448
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
447460 it("shows Git controls without requiring an environment indicator", () => {
448461 expect(
449462 shouldShowComposerContextStrip({
450463 hasActiveProject: true,
451464 isGitRepo: true,
452465 showEnvironmentIndicator: false,
466 hostsRestingComposerControls: false,
453467 }),
454468 ).toBe(true);
455469 });
apps/web/src/components/BranchToolbar.logic.ts+6-1
......@@ -59,8 +59,13 @@ export function shouldShowComposerContextStrip(input: {
5959 hasActiveProject: boolean;
6060 isGitRepo: boolean;
6161 showEnvironmentIndicator: boolean;
62 /** A collapsed composer's controls currently fit in their measured strip host. */
63 hostsRestingComposerControls: boolean;
6264}): boolean {
63 return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator);
65 return (
66 input.hasActiveProject &&
67 (input.isGitRepo || input.showEnvironmentIndicator || input.hostsRestingComposerControls)
68 );
6469}
6570
6671export 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
1212import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
1313import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
1414import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
15import { useIsMobile } from "../hooks/useMediaQuery";
1615import {
1716 type EnvMode,
1817 type EnvironmentOption,
......@@ -40,6 +39,7 @@ import {
4039} from "./ui/menu";
4140import { Separator } from "./ui/separator";
4241import { ComposerSurface } from "./chat/ComposerSurface";
42import { cn } from "~/lib/utils";
4343
4444interface BranchToolbarProps {
4545 environmentId: EnvironmentId;
......@@ -57,6 +57,8 @@ interface BranchToolbarProps {
5757 onComposerFocusRequest?: () => void;
5858 availableEnvironments?: readonly EnvironmentOption[];
5959 onEnvironmentChange?: (environmentId: EnvironmentId) => void;
60 composerControlsHostRef?: (element: HTMLDivElement | null) => void;
61 contextStripVisible?: boolean;
6062}
6163
6264interface MobileRunContextSelectorProps {
......@@ -120,15 +122,26 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
120122 const triggerContent = (
121123 <>
122124 {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>
125135 </span>
126136 </>
127137 );
128138
129139 if (isLocked) {
130140 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 >
132145 {triggerContent}
133146 </span>
134147 );
......@@ -138,7 +151,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
138151 <Menu>
139152 <MenuTrigger
140153 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
142156 >
143157 {triggerContent}
144158 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
......@@ -251,10 +265,14 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
251265 let counted = 0;
252266 for (const child of parent.children) {
253267 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;
256271 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);
258276 counted += 1;
259277 }
260278 return width + gap * Math.max(0, counted - 1);
......@@ -264,10 +282,15 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
264282 let groups = 0;
265283 for (const child of current.children) {
266284 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);
268291 if (width <= 1) continue;
269 needed += width;
270292 groups += 1;
293 needed += width;
271294 }
272295 needed += stripGap * Math.max(0, groups - 1);
273296 for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) {
......@@ -390,6 +413,8 @@ export const BranchToolbar = memo(function BranchToolbar({
390413 onComposerFocusRequest,
391414 availableEnvironments,
392415 onEnvironmentChange,
416 composerControlsHostRef,
417 contextStripVisible = true,
393418}: BranchToolbarProps) {
394419 const threadRef = useMemo(
395420 () => scopeThreadRef(environmentId, threadId),
......@@ -460,7 +485,6 @@ export const BranchToolbar = memo(function BranchToolbar({
460485 activeEnvironment: activeEnvironmentOption,
461486 canPickEnvironment: showEnvironmentPicker,
462487 });
463 const isMobile = useIsMobile();
464488 const [stripElement, setStripElement] = useState<HTMLDivElement | null>(null);
465489 const labelsOverflow = useLabelsOverflow(stripElement);
466490
......@@ -470,24 +494,40 @@ export const BranchToolbar = memo(function BranchToolbar({
470494 <ComposerSurface.ContextStrip
471495 ref={setStripElement}
472496 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 )}
473504 >
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 >
491531 {showEnvironmentIndicator && availableEnvironments && (
492532 <>
493533 <BranchToolbarEnvironmentSelector
......@@ -516,11 +556,23 @@ export const BranchToolbar = memo(function BranchToolbar({
516556 />
517557 ) : null}
518558 </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}
520572
521573 {showGitControls ? (
522574 <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"
524576 environmentId={environmentId}
525577 threadId={threadId}
526578 {...(draftId ? { draftId } : {})}
apps/web/src/components/BranchToolbarBranchSelector.tsx+12-2
......@@ -745,7 +745,17 @@ export function BranchToolbarBranchSelector({
745745 }
746746 >
747747 <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>
749759 </TooltipTrigger>
750760 <TooltipPopup side="top">{branchPrTooltip}</TooltipPopup>
751761 </Tooltip>
......@@ -759,7 +769,7 @@ export function BranchToolbarBranchSelector({
759769 >
760770 <ComboboxTrigger
761771 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"
763773 disabled={isInitialBranchesLoadPending || isBranchActionPending}
764774 >
765775 <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
5151 if (envLocked) {
5252 return (
5353 <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"
5555 data-composer-context-control
5656 >
5757 {activeWorktreePath ? (
......@@ -90,7 +90,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
9090 <SelectTrigger
9191 variant="ghost"
9292 size="xs"
93 className="min-w-0 shrink font-medium"
93 className="min-w-0 shrink font-normal text-xs!"
9494 aria-label="Workspace"
9595 data-composer-context-control
9696 >
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+2-2
......@@ -49,7 +49,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
4949 if (envLocked || onEnvironmentChange === undefined) {
5050 return (
5151 <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"
5353 data-composer-context-control
5454 >
5555 <EnvironmentMachineIcon
......@@ -81,7 +81,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
8181 <SelectTrigger
8282 variant="ghost"
8383 size="xs"
84 className="min-w-0 max-w-full font-medium"
84 className="min-w-0 max-w-full font-normal text-xs!"
8585 aria-label="Run on"
8686 data-composer-context-control
8787 >
apps/web/src/components/ChatView.tsx+70-11
......@@ -1491,6 +1491,9 @@ function ChatViewContent(props: ChatViewProps) {
14911491 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);
14921492 const localComposerRef = useRef<ChatComposerHandle | null>(null);
14931493 const composerRef = useComposerHandleContext() ?? localComposerRef;
1494 const [restingComposerControlsHost, setRestingComposerControlsHost] =
1495 useState<HTMLDivElement | null>(null);
1496 const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false);
14941497 const citeAssistantText = useCallback(
14951498 (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => {
14961499 const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false;
......@@ -1581,10 +1584,16 @@ function ChatViewContent(props: ChatViewProps) {
15811584 LastInvokedScriptByProjectSchema,
15821585 );
15831586 const legendListRef = useRef<LegendListRef | null>(null);
1587 const getTimelineScrollableNode = useCallback(
1588 () => legendListRef.current?.getScrollableNode() ?? null,
1589 [],
1590 );
15841591 const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null);
15851592 const [composerOverlayHeight, setComposerOverlayHeight] = useState(0);
1593 const composerOverlayHeightRef = useRef(0);
15861594 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
15871595 const isAtEndRef = useRef(true);
1596 const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []);
15881597 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
15891598 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
15901599 const sendInFlightRef = useRef(false);
......@@ -3000,10 +3009,20 @@ function ChatViewContent(props: ChatViewProps) {
30003009 terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;
30013010 // Default true while loading to avoid toolbar flicker.
30023011 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 });
30033021 const showComposerContextStrip = shouldShowComposerContextStrip({
30043022 hasActiveProject: activeProject !== null,
30053023 isGitRepo,
30063024 showEnvironmentIndicator: showComposerEnvironmentIndicator,
3025 hostsRestingComposerControls: routeKind === "server" && restingComposerControlsVisible,
30073026 });
30083027 const initialDiffPanelGitScope =
30093028 gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch";
......@@ -4336,6 +4355,7 @@ function ChatViewContent(props: ChatViewProps) {
43364355 const showScrollDebouncer = useRef(
43374356 new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
43384357 );
4358 const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null);
43394359 const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end");
43404360 // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd
43414361 // re-pins on its own (independent of the refs), so the timeline needs a
......@@ -4520,6 +4540,14 @@ function ChatViewContent(props: ChatViewProps) {
45204540 // Only an upward wheel is a navigation intent; wheeling down while
45214541 // following either does nothing (at the end) or moves toward it.
45224542 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 }
45234551 if (
45244552 event.deltaY < 0 &&
45254553 contentScrollsUp() &&
......@@ -4561,10 +4589,16 @@ function ChatViewContent(props: ChatViewProps) {
45614589 case "PageUp":
45624590 case "Home":
45634591 case "ArrowUp":
4592 timelineScrollIntentRef.current = "away-from-end";
45644593 if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) {
45654594 handleManualNavigation();
45664595 }
45674596 break;
4597 case "PageDown":
4598 case "End":
4599 case "ArrowDown":
4600 timelineScrollIntentRef.current = "toward-end";
4601 break;
45684602 default:
45694603 break;
45704604 }
......@@ -4655,6 +4689,9 @@ function ChatViewContent(props: ChatViewProps) {
46554689 if (isAtEndRef.current === isAtEnd) return;
46564690 isAtEndRef.current = isAtEnd;
46574691 if (isAtEnd) {
4692 if (timelineScrollIntentRef.current === "toward-end") {
4693 composerRef.current?.restoreAfterTimelineReachedEnd();
4694 }
46584695 timelineScrollModeRef.current = "following-end";
46594696 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
46604697 setTimelineLiveFollowEnabled(true);
......@@ -4727,6 +4764,7 @@ function ChatViewContent(props: ChatViewProps) {
47274764 useEffect(() => {
47284765 setPullRequestDialogState(null);
47294766 isAtEndRef.current = true;
4767 timelineScrollIntentRef.current = null;
47304768 timelineScrollModeRef.current = "following-end";
47314769 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
47324770 setTimelineLiveFollowEnabled(true);
......@@ -4856,18 +4894,25 @@ function ChatViewContent(props: ChatViewProps) {
48564894 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
48574895 ? activePlan.steps
48584896 : 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
48594911 useLayoutEffect(() => {
48604912 if (!composerOverlayElement) return;
48614913
48624914 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);
48714916 };
48724917
48734918 updateHeight();
......@@ -4878,7 +4923,7 @@ function ChatViewContent(props: ChatViewProps) {
48784923 return () => {
48794924 resizeObserver.disconnect();
48804925 };
4881 }, [composerOverlayElement]);
4926 }, [composerOverlayElement, publishComposerOverlayHeight]);
48824927 const activeThreadPr =
48834928 replacementLinkedThreadPullRequest !== null
48844929 ? (gitStatusQuery.data?.pr ?? null)
......@@ -7638,7 +7683,11 @@ function ChatViewContent(props: ChatViewProps) {
76387683 >
76397684 <Button
76407685 aria-label="Scroll to end"
7641 onClick={() => scrollToEnd(true)}
7686 onPointerDown={(event) => event.preventDefault()}
7687 onClick={() => {
7688 composerRef.current?.restoreAfterTimelineReachedEnd();
7689 scrollToEnd(true);
7690 }}
76427691 className="pointer-events-auto gap-1.5 rounded-full px-3 text-muted-foreground hover:text-foreground"
76437692 size="xs"
76447693 variant="glass"
......@@ -7756,6 +7805,14 @@ function ChatViewContent(props: ChatViewProps) {
77567805 keybindings={keybindings}
77577806 terminalOpen={Boolean(terminalUiState.terminalOpen)}
77587807 gitCwd={gitCwd}
7808 restingControlsHost={restingComposerControlsHost}
7809 restingControlsHaveLeadingContext={
7810 isGitRepo || showComposerEnvironmentIndicator
7811 }
7812 onRestingControlsVisibilityChange={setRestingComposerControlsVisible}
7813 getTimelineScrollableNode={getTimelineScrollableNode}
7814 isTimelineAtLogicalEnd={isTimelineAtLogicalEnd}
7815 onComposerOverlayHeightChange={publishComposerOverlayHeight}
77597816 promptRef={promptRef}
77607817 composerImagesRef={composerImagesRef}
77617818 composerFilesRef={composerFilesRef}
......@@ -7796,7 +7853,7 @@ function ChatViewContent(props: ChatViewProps) {
77967853 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}
77977854 className="relative z-0"
77987855 >
7799 {showComposerContextStrip && (
7856 {mountComposerContextStrip && (
78007857 <div className="pointer-events-auto">
78017858 <BranchToolbar
78027859 environmentId={activeThread.environmentId}
......@@ -7823,6 +7880,8 @@ function ChatViewContent(props: ChatViewProps) {
78237880 : {})}
78247881 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
78257882 availableEnvironments={logicalProjectEnvironments}
7883 composerControlsHostRef={setRestingComposerControlsHost}
7884 contextStripVisible={showComposerContextStrip}
78267885 />
78277886 </div>
78287887 )}
apps/web/src/components/ComposerPromptEditor.tsx+61-7
......@@ -87,6 +87,7 @@ import { getTimelinePageScrollKey } from "./chat/pageScrollController";
8787import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
8888import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
8989import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
90import { didComposerSelectionChangeVisibly } from "./composerSelection";
9091import {
9192 $consumeComposerCitationCommentRequest,
9293 $createComposerCitationNode,
......@@ -904,7 +905,9 @@ interface ComposerPromptEditorProps {
904905 skills: ReadonlyArray<ServerProviderSkill>;
905906 disabled: boolean;
906907 placeholder: string;
908 containerClassName?: string;
907909 className?: string;
910 placeholderClassName?: string;
908911 onRemoveTerminalContext: (contextId: string) => void;
909912 onChange: (
910913 nextValue: string,
......@@ -913,6 +916,7 @@ interface ComposerPromptEditorProps {
913916 cursorAdjacentToMention: boolean,
914917 terminalContextIds: string[],
915918 ) => void;
919 onVisibleSelectionChange?: () => void;
916920 onCommandKeyDown?: (
917921 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
918922 event: KeyboardEvent,
......@@ -1557,9 +1561,12 @@ function ComposerPromptEditorInner({
15571561 skills,
15581562 disabled,
15591563 placeholder,
1564 containerClassName,
15601565 className,
1566 placeholderClassName,
15611567 onRemoveTerminalContext,
15621568 onChange,
1569 onVisibleSelectionChange,
15631570 onCommandKeyDown,
15641571 onPageScrollKeyDown,
15651572 onPageScrollKeyUp,
......@@ -1569,7 +1576,9 @@ function ComposerPromptEditorInner({
15691576}: ComposerPromptEditorProps) {
15701577 const [editor] = useLexicalComposerContext();
15711578 const onChangeRef = useRef(onChange);
1579 const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange);
15721580 const initialCursor = clampCollapsedComposerCursor(value, cursor);
1581 const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor);
15731582 const terminalContextsSignature = terminalContextSignature(terminalContexts);
15741583 const terminalContextsSignatureRef = useRef(terminalContextsSignature);
15751584 const skillsSignature = skillSignature(skills);
......@@ -1578,9 +1587,10 @@ function ComposerPromptEditorInner({
15781587 const snapshotRef = useRef({
15791588 value,
15801589 cursor: initialCursor,
1581 expandedCursor: expandCollapsedComposerCursor(value, initialCursor),
1590 expandedCursor: initialExpandedCursor,
15821591 terminalContextIds: terminalContexts.map((context) => context.id),
15831592 });
1593 const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor });
15841594 const isApplyingControlledUpdateRef = useRef(false);
15851595 const citationCommentRequestRef = useRef<ComposerCitationCommentRequest | null>(null);
15861596 const [openCitationComment, setOpenCitationComment] =
......@@ -1605,6 +1615,10 @@ function ComposerPromptEditorInner({
16051615 onChangeRef.current = onChange;
16061616 }, [onChange]);
16071617
1618 useEffect(() => {
1619 onVisibleSelectionChangeRef.current = onVisibleSelectionChange;
1620 }, [onVisibleSelectionChange]);
1621
16081622 useLayoutEffect(() => {
16091623 skillMetadataRef.current = skillMetadataByName(skills);
16101624 }, [skills]);
......@@ -1643,12 +1657,17 @@ function ComposerPromptEditorInner({
16431657 return;
16441658 }
16451659
1660 const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor);
16461661 snapshotRef.current = {
16471662 value,
16481663 cursor: normalizedCursor,
1649 expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor),
1664 expandedCursor: normalizedExpandedCursor,
16501665 terminalContextIds: terminalContexts.map((context) => context.id),
16511666 };
1667 selectionRangeRef.current = {
1668 start: normalizedExpandedCursor,
1669 end: normalizedExpandedCursor,
1670 };
16521671 terminalContextsSignatureRef.current = terminalContextsSignature;
16531672 skillsSignatureRef.current = skillsSignature;
16541673
......@@ -1700,6 +1719,10 @@ function ComposerPromptEditorInner({
17001719 expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),
17011720 terminalContextIds: snapshotRef.current.terminalContextIds,
17021721 };
1722 selectionRangeRef.current = {
1723 start: snapshotRef.current.expandedCursor,
1724 end: snapshotRef.current.expandedCursor,
1725 };
17031726 onChangeRef.current(
17041727 snapshotRef.current.value,
17051728 boundedCursor,
......@@ -1733,6 +1756,7 @@ function ComposerPromptEditorInner({
17331756 nextValue,
17341757 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
17351758 );
1759 const selectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection());
17361760 const terminalContextIds = collectTerminalContextIds($getRoot());
17371761 snapshot = {
17381762 value: nextValue,
......@@ -1740,6 +1764,10 @@ function ComposerPromptEditorInner({
17401764 expandedCursor: nextExpandedCursor,
17411765 terminalContextIds,
17421766 };
1767 selectionRangeRef.current = selectionRange ?? {
1768 start: nextExpandedCursor,
1769 end: nextExpandedCursor,
1770 };
17431771 });
17441772 snapshotRef.current = snapshot;
17451773 return snapshot;
......@@ -1788,18 +1816,28 @@ function ComposerPromptEditorInner({
17881816 nextValue,
17891817 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
17901818 );
1819 const nextSelectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection());
1820 const previousSelectionRange = selectionRangeRef.current;
1821 selectionRangeRef.current = nextSelectionRange ?? {
1822 start: nextExpandedCursor,
1823 end: nextExpandedCursor,
1824 };
17911825 const terminalContextIds = collectTerminalContextIds($getRoot());
17921826 const previousSnapshot = snapshotRef.current;
1793 if (
1827 const snapshotChanged = !(
17941828 previousSnapshot.value === nextValue &&
17951829 previousSnapshot.cursor === nextCursor &&
17961830 previousSnapshot.expandedCursor === nextExpandedCursor &&
17971831 previousSnapshot.terminalContextIds.length === terminalContextIds.length &&
17981832 previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index])
1799 ) {
1833 );
1834 if (isApplyingControlledUpdateRef.current) {
18001835 return;
18011836 }
1802 if (isApplyingControlledUpdateRef.current) {
1837 if (!snapshotChanged) {
1838 if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) {
1839 onVisibleSelectionChangeRef.current?.();
1840 }
18031841 return;
18041842 }
18051843 snapshotRef.current = {
......@@ -1824,7 +1862,12 @@ function ComposerPromptEditorInner({
18241862 return (
18251863 <ComposerTerminalContextActionsContext value={terminalContextActions}>
18261864 <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 >
18281871 <PlainTextPlugin
18291872 contentEditable={
18301873 <ContentEditable
......@@ -1881,7 +1924,12 @@ function ComposerPromptEditorInner({
18811924 }
18821925 placeholder={
18831926 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 >
18851933 {placeholder}
18861934 </div>
18871935 )
......@@ -1911,9 +1959,12 @@ export function ComposerPromptEditor({
19111959 skills,
19121960 disabled,
19131961 placeholder,
1962 containerClassName,
19141963 className,
1964 placeholderClassName,
19151965 onRemoveTerminalContext,
19161966 onChange,
1967 onVisibleSelectionChange,
19171968 onCommandKeyDown,
19181969 onPageScrollKeyDown,
19191970 onPageScrollKeyUp,
......@@ -1957,8 +2008,10 @@ export function ComposerPromptEditor({
19572008 skills={skills}
19582009 disabled={disabled}
19592010 placeholder={placeholder}
2011 {...(containerClassName ? { containerClassName } : {})}
19602012 onRemoveTerminalContext={onRemoveTerminalContext}
19612013 onChange={onChange}
2014 {...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})}
19622015 onPaste={onPaste}
19632016 editorRef={editorRef}
19642017 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
......@@ -1966,6 +2019,7 @@ export function ComposerPromptEditor({
19662019 {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})}
19672020 {...(onPageScrollRelease ? { onPageScrollRelease } : {})}
19682021 {...(className ? { className } : {})}
2022 {...(placeholderClassName ? { placeholderClassName } : {})}
19692023 />
19702024 </LexicalComposer>
19712025 );
apps/web/src/components/chat/AssistantCitationChip.tsx+2
......@@ -23,6 +23,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2323import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
2424import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";
2525import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
26import { composerFloatingLayerProps } from "./composerEventScope";
2627
2728const CITATION_ACTION_BUTTON_CLASS_NAME = cn(
2829 COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME,
......@@ -135,6 +136,7 @@ export function AssistantCitationChip({
135136 </PopoverTrigger>
136137 {commentEditor.open ? (
137138 <PopoverPopup
139 {...composerFloatingLayerProps}
138140 side={sourceAnchor ? "bottom" : "top"}
139141 align="end"
140142 anchor={popupAnchor}
apps/web/src/components/chat/ChatComposer.tsx+1200-128
......@@ -23,6 +23,7 @@ import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/
2323import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
2424import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
2525import {
26 Fragment,
2627 memo,
2728 type ReactNode,
2829 useCallback,
......@@ -51,6 +52,11 @@ import {
5152 dataTransferHasComposerMention,
5253 makeComposerMentionDragHandlers,
5354} from "./composerMentionDrag";
55import {
56 composerFloatingLayerProps,
57 isInsideComposerFloatingLayer,
58 isInsideRestingComposerControlScope,
59} from "./composerEventScope";
5460import {
5561 type ComposerFileAttachment,
5662 type ComposerImageAttachment,
......@@ -137,8 +143,12 @@ import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards
137143import {
138144 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
139145 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
146 getRestingComposerImagePreviewCounts,
147 resolveRestingComposerControlsLayout,
148 shouldAnimateComposerRestingTransition,
140149 shouldUseCompactComposerPrimaryActions,
141150 shouldUseCompactComposerFooter,
151 shouldUseRestingComposerLayout,
142152} from "../composerFooterLayout";
143153import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";
144154import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";
......@@ -150,7 +160,12 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
150160import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
151161import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
152162import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
153import { ComposerControl, ComposerControlIcon, ComposerSelectControl } from "./ComposerControl";
163import {
164 ComposerControl,
165 ComposerControlIcon,
166 ComposerControlSeparator,
167 ComposerSelectControl,
168} from "./ComposerControl";
154169import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
155170import {
156171 searchSlashCommandItems,
......@@ -171,13 +186,18 @@ import {
171186} from "./ExpandedImagePreview";
172187import { basenameOfPath } from "../../pierre-icons";
173188import { cn, randomUUID } from "~/lib/utils";
174import { Separator } from "../ui/separator";
175189import {
176190 getComposerPromptLengthValidationMessage,
177191 getComposerSubmissionValidationMessage,
178192 submitComposerDraft,
179193} from "./composerSubmission";
180194import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
195import {
196 createComposerScrollGestureState,
197 recordComposerScrollGestureEvent,
198 resetComposerScrollGesture,
199 suppressActiveComposerScrollGesture,
200} from "./composerScrollGesture";
181201import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
182202
183203function ComposerVideoThumbnail({ file }: { file: File }) {
......@@ -209,6 +229,435 @@ type ComposerCommandMenuPosition = {
209229 width: number;
210230};
211231
232const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24;
233const COMPOSER_SCROLL_GESTURE_RESET_MS = 120;
234const COMPOSER_RESTING_TRANSITION_DURATION_MS = 280;
235const COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS = 50;
236const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
237const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4;
238
239function 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
212661function composerCommandMenuPositionsEqual(
213662 a: ComposerCommandMenuPosition,
214663 b: ComposerCommandMenuPosition,
......@@ -375,15 +824,6 @@ const runtimeModeConfig: Record<
375824};
376825
377826const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
378const 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
387827const extendReplacementRangeForTrailingSpace = (
388828 text: string,
389829 rangeEnd: number,
......@@ -412,17 +852,122 @@ const terminalContextIdListsEqual = (
412852): boolean =>
413853 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
414854
415function isInsideComposerFloatingLayer(element: Element): boolean {
416 return element.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null;
855function 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
866function 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
874function 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
888function providerModelPickerMinimumWidth(picker: HTMLElement): number {
889 const minWidth = Number.parseFloat(getComputedStyle(picker).minWidth) || 0;
890 return minWidth + elementInlineMarginWidth(picker);
891}
892
893function 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 };
417960}
418961
419962const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
420963 showInteractionModeToggle: boolean;
421964 interactionMode: ProviderInteractionMode;
422965 runtimeMode: RuntimeMode;
966 size?: "sm" | "xs";
423967 onToggleInteractionMode: () => void;
424968 onRuntimeModeChange: (mode: RuntimeMode) => void;
425969}) {
970 const size = props.size ?? "sm";
426971 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];
427972 const RuntimeModeIcon = runtimeModeOption.icon;
428973 const interactionModeTooltip =
......@@ -432,16 +977,19 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
432977
433978 const interactionModeToggle = props.showInteractionModeToggle ? (
434979 <>
435 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
980 <ComposerControlSeparator size={size} />
436981 <Tooltip>
437982 <TooltipTrigger
438983 render={
439984 <ComposerControl
985 size={size}
440986 className={cn(
441987 "shrink-0 whitespace-nowrap",
442988 props.interactionMode === "plan"
443989 ? "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",
445993 )}
446994 type="button"
447995 onClick={props.onToggleInteractionMode}
......@@ -450,9 +998,17 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
450998 }
451999 >
4521000 {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 />
4541006 ) : (
455 <ComposerControlIcon icon={BotIcon} opticalSize="large" />
1007 <ComposerControlIcon
1008 icon={BotIcon}
1009 size={size}
1010 opticalSize={size === "xs" ? "default" : "large"}
1011 />
4561012 )}
4571013 <span className="sr-only sm:not-sr-only">
4581014 {props.interactionMode === "plan" ? "Plan" : "Build"}
......@@ -465,7 +1021,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
4651021
4661022 return (
4671023 <>
468 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
1024 <ComposerControlSeparator size={size} />
4691025
4701026 <Tooltip>
4711027 <Select
......@@ -473,12 +1029,18 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
4731029 onValueChange={(value) => props.onRuntimeModeChange(value!)}
4741030 >
4751031 <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 }
4771039 >
478 <ComposerControlIcon icon={RuntimeModeIcon} />
1040 <ComposerControlIcon icon={RuntimeModeIcon} size={size} />
4791041 <SelectValue>{runtimeModeOption.label}</SelectValue>
4801042 </TooltipTrigger>
481 <SelectPopup alignItemWithTrigger={false}>
1043 <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}>
4821044 {runtimeModeOptions.map((mode) => {
4831045 const option = runtimeModeConfig[mode];
4841046 const OptionIcon = option.icon;
......@@ -530,6 +1092,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
5301092 hasSendableContent: boolean;
5311093 preserveComposerFocusOnPointerDown?: boolean;
5321094 showSendWhileRunning?: boolean;
1095 showSecondaryStatus: boolean;
5331096 onPreviousPendingQuestion: () => void;
5341097 onInterrupt: () => void;
5351098 onImplementPlanInNewThread: () => void;
......@@ -539,7 +1102,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
5391102}) {
5401103 return (
5411104 <>
542 {props.activeContextWindow ? (
1105 {props.showSecondaryStatus && props.activeContextWindow ? (
5431106 <ContextWindowMeter
5441107 usage={props.activeContextWindow}
5451108 modelDisplayName={props.activeThreadModelDisplayName}
......@@ -577,6 +1140,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
5771140export interface ChatComposerHandle {
5781141 focusAtEnd: () => void;
5791142 focusAt: (cursor: number) => void;
1143 /** Undo only a scroll-triggered collapse when the timeline returns to its live edge. */
1144 restoreAfterTimelineReachedEnd: () => void;
5801145 addDroppedFiles: (files: File[]) => void;
5811146 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
5821147 citeAssistantText: (
......@@ -702,6 +1267,12 @@ export interface ChatComposerProps {
7021267 keybindings: ResolvedKeybindingsConfig;
7031268 terminalOpen: boolean;
7041269 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;
7051276
7061277 // Refs the parent needs kept in sync
7071278 promptRef: React.RefObject<string>;
......@@ -798,6 +1369,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7981369 keybindings,
7991370 terminalOpen,
8001371 gitCwd,
1372 restingControlsHost,
1373 restingControlsHaveLeadingContext,
1374 onRestingControlsVisibilityChange,
1375 getTimelineScrollableNode,
1376 isTimelineAtLogicalEnd,
1377 onComposerOverlayHeightChange,
8011378 promptRef,
8021379 composerRef,
8031380 composerImagesRef,
......@@ -849,6 +1426,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
8491426 const composerElementContexts = composerDraft.elementContexts;
8501427 const composerPreviewAnnotations = composerDraft.previewAnnotations;
8511428 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]);
8521435 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
8531436 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
8541437 const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;
......@@ -1288,6 +1871,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
12881871 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
12891872 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
12901873 const [isComposerFocused, setIsComposerFocused] = useState(false);
1874 const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false);
12911875 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);
12921876 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(
12931877 null,
......@@ -1322,6 +1906,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13221906 const mobileComposerExpandFrameRef = useRef<number | null>(null);
13231907 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
13241908 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());
13251915 const stashPulseKeyRef = useRef(0);
13261916 const stashPulseTimeoutRef = useRef<number | null>(null);
13271917 /**
......@@ -1509,7 +2099,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
15092099 (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);
15102100 const showCollapsedMobilePromptRow =
15112101 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
1512
2102 const showComposerAttachAction = fileStagingLimit !== null && pendingUserInputs.length === 0;
15132103 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
15142104 const composerFooterActionLayoutKey = useMemo(() => {
15152105 if (activePendingProgress) {
......@@ -1576,7 +2166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
15762166 onPromptChange: setPromptFromTraits,
15772167 planModeEnabled: settings.planModeEnabled,
15782168 });
1579 const providerTraitsPicker = renderProviderTraitsPicker({
2169 const providerTraitsPickerInput = {
15802170 provider: selectedProvider,
15812171 instanceId: selectedInstanceId,
15822172 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
......@@ -1587,7 +2177,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
15872177 prompt,
15882178 onPromptChange: setPromptFromTraits,
15892179 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",
15902186 });
2187 const {
2188 controlsRef: restingComposerControlsRef,
2189 hiddenBlockCount: restingControlsHiddenBlockCount,
2190 controlsVisible: restingControlsVisible,
2191 } = useRestingComposerControlsLayout(restingControlsHost);
15912192 const pendingPrimaryAction = useMemo(
15922193 () =>
15932194 activePendingProgress
......@@ -1820,6 +2421,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18202421 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
18212422 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
18222423 setIsDragOverComposer(false);
2424 setIsComposerScrollCollapsed(false);
18232425 }, [draftId, activeThreadId, promptRef]);
18242426
18252427 // ------------------------------------------------------------------
......@@ -1963,6 +2565,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19632565 // ------------------------------------------------------------------
19642566 // Callbacks: prompt change
19652567 // ------------------------------------------------------------------
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
19662578 const onPromptChange = useCallback(
19672579 (
19682580 nextPrompt: string,
......@@ -1971,6 +2583,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19712583 cursorAdjacentToMention: boolean,
19722584 terminalContextIds: string[],
19732585 ) => {
2586 expandComposerForEditorChange();
19742587 if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {
19752588 setComposerCursor(nextCursor);
19762589 setComposerTrigger(
......@@ -2000,6 +2613,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20002613 },
20012614 [
20022615 activePendingProgress?.activeQuestion,
2616 expandComposerForEditorChange,
20032617 pendingUserInputs.length,
20042618 onChangeActivePendingUserInputCustomAnswer,
20052619 promptRef,
......@@ -2061,6 +2675,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20612675 setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor));
20622676 if (options?.focusEditorAfterReplace !== false) {
20632677 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;
20642683 composerEditorRef.current?.focusAt(nextCursor);
20652684 });
20662685 }
......@@ -2644,7 +3263,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26443263 // unique image into the overflow list for nothing.
26453264 const existingDedupKeys = new Set(
26463265 composerImagesRef.current.map(
2647 (image) => `${image.mimeType}${image.sizeBytes}${image.name}`,
3266 (image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`,
26483267 ),
26493268 );
26503269 const capacity = Math.max(
......@@ -2658,7 +3277,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26583277 (attachment) =>
26593278 !existingIds.has(attachment.id) &&
26603279 !existingDedupKeys.has(
2661 `${attachment.mimeType}${attachment.sizeBytes}${attachment.name}`,
3280 `${attachment.mimeType}\0${attachment.sizeBytes}\0${attachment.name}`,
26623281 ),
26633282 );
26643283 // Anything past the attachment limit cannot be restored. The entry is
......@@ -2802,10 +3421,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28023421 // the composer has been cleared the user can type something genuinely
28033422 // new (or switch threads) while encoding continues, and that deserves its
28043423 // own entry.
2805 const snapshotKey = `${String(composerDraftTarget)}${prompt}${images
3424 const attachmentKey = images
28063425 .map((image) => `image:${image.id}`)
28073426 .concat(files.map((file) => `file:${file.id}`))
2808 .join(",")}`;
3427 .join(",");
3428 const snapshotKey = [String(composerDraftTarget), prompt, attachmentKey].join("\n");
28093429 if (stashInFlightRef.current.has(snapshotKey)) return;
28103430 stashInFlightRef.current.add(snapshotKey);
28113431
......@@ -2986,6 +3606,365 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29863606 steps={activeTaskSteps}
29873607 />
29883608 ) : 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 );
29893968 const showTasksTab =
29903969 !hasBannerItems &&
29913970 !showComposerTopDrawer &&
......@@ -3318,8 +4297,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
33184297 );
33194298
33204299 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],
33234311 );
33244312
33254313 // 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)
33644352 void onImplementPlanInNewThread();
33654353 }, [onImplementPlanInNewThread]);
33664354 const scheduleComposerCollapseCheck = useCallback(() => {
3367 if (!isMobileViewport) {
3368 return;
3369 }
3370 if (mobileComposerExpandInFlightRef.current) {
4355 if (isMobileViewport && mobileComposerExpandInFlightRef.current) {
33714356 return;
33724357 }
33734358 if (composerBlurFrameRef.current !== null) {
......@@ -3375,7 +4360,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
33754360 }
33764361 composerBlurFrameRef.current = window.requestAnimationFrame(() => {
33774362 composerBlurFrameRef.current = null;
3378 if (mobileComposerExpandInFlightRef.current) {
4363 if (isMobileViewport && mobileComposerExpandInFlightRef.current) {
4364 return;
4365 }
4366 if (!isMobileViewport && desktopOutsidePointerInFlightRef.current) {
33794367 return;
33804368 }
33814369 const composerSurface = composerSurfaceRef.current;
......@@ -3395,6 +4383,73 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
33954383 });
33964384 }, [isMobileViewport]);
33974385
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
33984453 useEffect(() => {
33994454 return () => {
34004455 if (composerBlurFrameRef.current !== null) {
......@@ -3421,6 +4476,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34214476 focusAt: (cursor: number) => {
34224477 composerEditorRef.current?.focusAt(cursor);
34234478 },
4479 restoreAfterTimelineReachedEnd: () => {
4480 setIsComposerScrollCollapsed(false);
4481 },
34244482 addDroppedFiles: (files: File[]) => {
34254483 void addComposerAttachments(files);
34264484 focusComposer();
......@@ -3564,8 +4622,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35644622 <form
35654623 ref={composerFormRef}
35664624 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 }}
35674645 onFocusCapture={(event) => {
35684646 const activeElement = event.target;
4647 if (composerControlsInStrip && isInsideRestingComposerControlScope(activeElement)) {
4648 return;
4649 }
35694650 if (
35704651 isComposerCollapsedMobile &&
35714652 activeElement instanceof HTMLElement &&
......@@ -3573,6 +4654,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35734654 ) {
35744655 return;
35754656 }
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 }
35764663 if (composerBlurFrameRef.current !== null) {
35774664 window.cancelAnimationFrame(composerBlurFrameRef.current);
35784665 composerBlurFrameRef.current = null;
......@@ -3582,13 +4669,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35824669 onBlurCapture={() => {
35834670 scheduleComposerCollapseCheck();
35844671 }}
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 }}
35894688 className="mx-auto w-full min-w-0 max-w-3xl"
35904689 data-chat-composer-form="true"
35914690 >
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}
35924708 <ComposerBanner.Dock>
35934709 <ComposerBanner.Column>
35944710 <ComposerBannerStack
......@@ -3746,7 +4862,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37464862 ) : null}
37474863 </ComposerBanner.Dock>
37484864 <div className="relative">
3749 <ComposerSurface.Main className={composerProviderState.composerFrameClassName}>
4865 <ComposerSurface.Main
4866 ref={composerMainSurfaceRef}
4867 className={composerProviderState.composerFrameClassName}
4868 >
37504869 <div
37514870 ref={composerSurfaceRef}
37524871 data-chat-composer-surface="true"
......@@ -3762,6 +4881,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37624881 <div className="flex items-center justify-between gap-2 px-3 py-2">
37634882 <button
37644883 type="button"
4884 data-chat-composer-transition-prompt="true"
37654885 className={cn(
37664886 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
37674887 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
......@@ -3778,8 +4898,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37784898 : prompt.trim() ||
37794899 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
37804900 </button>
4901 {collapsedComposerImagePreviews}
37814902 <button
37824903 type="button"
4904 data-chat-composer-transition-actions="true"
37834905 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"
37844906 disabled={collapsedComposerPrimaryActionDisabled}
37854907 aria-label={collapsedComposerPrimaryActionLabel}
......@@ -3804,11 +4926,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38044926
38054927 <div
38064928 ref={setComposerMenuAnchor}
4929 data-chat-composer-body="true"
38074930 className={cn(
38084931 "relative px-3 pb-2 sm:px-4",
38094932 "pt-3.5 sm:pt-4",
38104933 isComposerApprovalState && "pb-3 sm:pb-4",
38114934 isComposerCollapsedMobile && "hidden",
4935 isComposerResting && "py-2 sm:py-2",
38124936 )}
38134937 >
38144938 {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && (
......@@ -3904,25 +5028,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39045028 !isComposerApprovalState &&
39055029 pendingUserInputs.length === 0 &&
39065030 (composerVideos.length > 0 ||
3907 composerImages.some(
3908 (image) =>
3909 !composerPreviewAnnotations.some((annotation) => annotation.id === image.id),
3910 )) && (
5031 (!isComposerResting && standaloneComposerImages.length > 0)) && (
39115032 <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) => {
39205035 const upload = supportsAttachmentUploads
39215036 ? uploadsByImageId[image.id]
39225037 : undefined;
39235038 return (
39245039 <div
39255040 key={image.id}
5041 data-chat-composer-expanded-image="true"
39265042 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
39275043 >
39285044 {image.previewUrl ? (
......@@ -4172,7 +5288,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
41725288 </div>
41735289 )}
41745290
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 >
41765298 <ComposerPromptEditor
41775299 editorRef={composerEditorRef}
41785300 value={
......@@ -4189,9 +5311,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
41895311 : []
41905312 }
41915313 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 )}
41935324 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
41945325 onChange={onPromptChange}
5326 onVisibleSelectionChange={expandComposerForEditorChange}
41955327 onCommandKeyDown={onComposerCommandKey}
41965328 onPageScrollKeyDown={onPageScrollKeyDown}
41975329 onPageScrollKeyUp={onPageScrollKeyUp}
......@@ -4215,6 +5347,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42155347 }
42165348 disabled={isConnecting || isComposerApprovalState || projectSelectionRequired}
42175349 />
5350 {isComposerResting ? collapsedComposerImagePreviews : null}
42185351 {showMobilePendingAnswerActions ? (
42195352 <div
42205353 data-chat-composer-mobile-pending-actions="true"
......@@ -4260,94 +5393,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42605393 pendingUserInputs.length > 0 && "pt-2",
42615394 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
42625395 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",
42635398 )}
42645399 >
42655400 <div
42665401 ref={composerFooterControlsRef}
5402 data-chat-composer-controls="left"
42675403 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",
43395407 )}
5408 >
5409 {composerControlsInStrip ? null : composerControls}
43405410 </div>
43415411
43425412 {/* Right side: send / stop button */}
43435413 <div
43445414 data-chat-composer-actions="right"
5415 data-chat-composer-transition-actions="true"
43455416 data-chat-composer-primary-actions-compact={
43465417 isComposerPrimaryActionsCompact ? "true" : "false"
43475418 }
43485419 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
43495420 >
4350 {fileStagingLimit !== null && pendingUserInputs.length === 0 ? (
5421 {showComposerAttachAction ? (
43515422 <>
43525423 <input
43535424 ref={attachmentInputRef}
......@@ -4381,7 +5452,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
43815452 </>
43825453 ) : null}
43835454 <ComposerFooterPrimaryActions
4384 compact={isComposerPrimaryActionsCompact}
5455 compact={isComposerResting || isComposerPrimaryActionsCompact}
43855456 activeContextWindow={
43865457 settings.contextWindowMeterEnabled ? activeContextWindow : null
43875458 }
......@@ -4402,8 +5473,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44025473 }
44035474 isPreparingWorktree={isPreparingWorktree}
44045475 hasSendableContent={composerSendState.hasSendableContent}
4405 preserveComposerFocusOnPointerDown={isMobileViewport}
5476 preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting}
44065477 showSendWhileRunning={isMobileViewport}
5478 showSecondaryStatus={!isComposerResting}
44075479 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
44085480 onInterrupt={handleInterruptPrimaryAction}
44095481 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+10-6
......@@ -1,7 +1,6 @@
11import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";
22import { memo, type ReactNode } from "react";
33import { EllipsisIcon } from "lucide-react";
4import { Button } from "../ui/button";
54import {
65 Menu,
76 MenuPopup,
......@@ -10,30 +9,35 @@ import {
109 MenuSeparator as MenuDivider,
1110 MenuTrigger,
1211} from "../ui/menu";
12import { ComposerControl, ComposerControlIcon } from "./ComposerControl";
13import { composerFloatingLayerProps } from "./composerEventScope";
1314
1415export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
1516 interactionMode: ProviderInteractionMode;
1617 runtimeMode: RuntimeMode;
1718 showInteractionModeToggle: boolean;
1819 traitsMenuContent?: ReactNode;
20 size?: "sm" | "xs";
1921 onToggleInteractionMode: () => void;
2022 onRuntimeModeChange: (mode: RuntimeMode) => void;
2123}) {
24 const size = props.size ?? "sm";
25
2226 return (
2327 <Menu>
2428 <MenuTrigger
2529 render={
26 <Button
27 size="sm"
30 <ComposerControl
31 size={size}
2832 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"}
3034 aria-label="More composer controls"
3135 />
3236 }
3337 >
34 <EllipsisIcon aria-hidden="true" className="size-4" />
38 <ComposerControlIcon icon={EllipsisIcon} size={size} />
3539 </MenuTrigger>
36 <MenuPopup align="start">
40 <MenuPopup align="start" {...composerFloatingLayerProps}>
3741 {props.traitsMenuContent ? (
3842 <>
3943 {props.traitsMenuContent}
apps/web/src/components/chat/ComposerControl.test.tsx created+76
......@@ -0,0 +1,76 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { BotIcon } from "lucide-react";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 ComposerControl,
7 ComposerControlChevron,
8 ComposerControlIcon,
9 ComposerControlSeparator,
10} from "./ComposerControl";
11
12describe("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";
44import { cn } from "~/lib/utils";
55import { Button } from "../ui/button";
66import { SelectTrigger } from "../ui/select";
7import { Separator } from "../ui/separator";
8
9export type ComposerControlSize = "sm" | "xs";
10
11type ComposerControlProps = Omit<ComponentProps<typeof Button>, "size"> & {
12 size?: ComposerControlSize;
13};
14
15type ComposerSelectControlProps = Omit<ComponentProps<typeof SelectTrigger>, "size"> & {
16 size?: ComposerControlSize;
17};
718
819const 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";
21const expandedComposerControlClassName = "h-7 min-h-7 gap-1.5 px-2.5";
22const 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";
1024
1125export function ComposerControl({
1226 className,
1327 size = "sm",
1428 variant = "ghost",
1529 ...props
16}: ComponentProps<typeof Button>) {
30}: ComposerControlProps) {
1731 return (
1832 <Button
19 className={cn(composerControlClassName, className)}
33 className={cn(
34 composerControlClassName,
35 size === "xs" ? restingComposerControlClassName : expandedComposerControlClassName,
36 className,
37 )}
2038 size={size}
2139 variant={variant}
2240 {...props}
......@@ -28,41 +46,77 @@ export function ComposerControlIcon({
2846 icon: Icon,
2947 className,
3048 opticalSize = "default",
49 size = "sm",
3150}: {
3251 icon: LucideIcon;
3352 className?: string | undefined;
3453 opticalSize?: "default" | "large";
54 size?: ComposerControlSize;
3555}) {
3656 return (
3757 <Icon
3858 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 )}
4064 data-composer-control-icon
4165 />
4266 );
4367}
4468
45export function ComposerControlChevron() {
69export function ComposerControlChevron({
70 className,
71 size = "sm",
72}: {
73 className?: string;
74 size?: ComposerControlSize;
75} = {}) {
4676 return (
4777 <ChevronDownIcon
4878 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 )}
5084 data-composer-control-chevron
5185 strokeWidth={2.25}
5286 />
5387 );
5488}
5589
90export 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
56106export function ComposerSelectControl({
57107 className,
58108 size = "sm",
59109 variant = "ghost",
60110 ...props
61}: ComponentProps<typeof SelectTrigger>) {
111}: ComposerSelectControlProps) {
62112 return (
63113 <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} />}
66120 size={size}
67121 variant={variant}
68122 {...props}
apps/web/src/components/chat/ComposerPrimaryActions.tsx+2-1
......@@ -6,6 +6,7 @@ import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../Sideb
66import { Button } from "../ui/button";
77import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
88import { Spinner } from "../ui/spinner";
9import { composerFloatingLayerProps } from "./composerEventScope";
910
1011interface PendingActionState {
1112 questionIndex: number;
......@@ -205,7 +206,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
205206 >
206207 <ChevronDownIcon className="size-3.5" />
207208 </MenuTrigger>
208 <MenuPopup align="end" side="top">
209 <MenuPopup align="end" side="top" {...composerFloatingLayerProps}>
209210 <MenuItem
210211 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
211212 onClick={() => void onImplementPlanInNewThread()}
apps/web/src/components/chat/ComposerSurface.tsx+1-1
......@@ -13,7 +13,7 @@ function Shell({
1313 data-slot="composer-shell"
1414 data-with-context={contextStrip || undefined}
1515 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",
1717 "[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]",
1818 "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)]",
1919 "[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
33import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
44import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic";
55import { Minimize2Icon } from "lucide-react";
6import { composerFloatingLayerProps } from "./composerEventScope";
67
78function formatPercentage(value: number | null): string | null {
89 if (value === null || !Number.isFinite(value)) {
......@@ -83,6 +84,7 @@ export function ContextWindowMeter(props: {
8384 }
8485 />
8586 <PopoverPopup
87 {...composerFloatingLayerProps}
8688 tooltipStyle
8789 side="top"
8890 align="end"
apps/web/src/components/chat/MessagesTimeline.test.tsx+45-8
......@@ -3,7 +3,7 @@ import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
33import { createRef, type ReactNode, type Ref } from "react";
44import { renderToStaticMarkup } from "react-dom/server";
55import { beforeAll, describe, expect, it, vi } from "vite-plus/test";
6import type { LegendListRef } from "@legendapp/list/react";
6import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react";
77
88vi.mock("@legendapp/list/react", async () => {
99 const legendListTestId = "legend-list";
......@@ -16,23 +16,34 @@ vi.mock("@legendapp/list/react", async () => {
1616 ListFooterComponent?: ReactNode;
1717 anchoredEndSpace?: {
1818 anchorIndex: number;
19 anchorMaxSize?: number;
20 anchorOffset?: number;
21 onReady?: (info: { anchorIndex: number }) => void;
1922 };
20 maintainScrollAtEnd?:
23 contentInsetEndAdjustment?: number;
24 className?: string;
25 maintainScrollAtEnd?: boolean | MaintainScrollAtEndOptions;
26 maintainVisibleContentPosition?:
2127 | boolean
2228 | {
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;
2932 };
3033 ref?: Ref<LegendListRef>;
3134 }) => {
35 if (props.anchoredEndSpace) {
36 props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex });
37 }
3238 return (
3339 <div
3440 data-testid={legendListTestId}
3541 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}
3647 data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "enabled" : undefined}
3748 data-maintain-scroll-at-end-animated={
3849 typeof props.maintainScrollAtEnd === "object"
......@@ -44,6 +55,11 @@ vi.mock("@legendapp/list/react", async () => {
4455 ? props.maintainScrollAtEnd.on?.dataChange
4556 : undefined
4657 }
58 data-maintain-scroll-at-end-footer-layout={
59 typeof props.maintainScrollAtEnd === "object"
60 ? props.maintainScrollAtEnd.on?.footerLayout
61 : undefined
62 }
4763 data-maintain-scroll-at-end-item-layout={
4864 typeof props.maintainScrollAtEnd === "object"
4965 ? props.maintainScrollAtEnd.on?.itemLayout
......@@ -54,6 +70,26 @@ vi.mock("@legendapp/list/react", async () => {
5470 ? props.maintainScrollAtEnd.on?.layout
5571 : undefined
5672 }
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 }
5793 >
5894 {props.ListHeaderComponent}
5995 {props.data.map((item) => (
......@@ -704,6 +740,7 @@ describe("MessagesTimeline", () => {
704740 expect(markup).toContain('data-maintain-scroll-at-end="enabled"');
705741 expect(markup).toContain('data-maintain-scroll-at-end-animated="false"');
706742 expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"');
743 expect(markup).toContain('data-maintain-scroll-at-end-footer-layout="false"');
707744 expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"');
708745 expect(markup).toContain('data-maintain-scroll-at-end-layout="true"');
709746 expect(markup).toContain('data-user-message-collapsed="true"');
apps/web/src/components/chat/MessagesTimeline.tsx+26-20
......@@ -44,7 +44,11 @@ import {
4444 type MouseEvent,
4545 type ReactNode,
4646} from "react";
47import { LegendList, type LegendListRef } from "@legendapp/list/react";
47import {
48 LegendList,
49 type LegendListRef,
50 type MaintainScrollAtEndOptions,
51} from "@legendapp/list/react";
4852import { FileDiff } from "@pierre/diffs/react";
4953import {
5054 deriveTimelineEntries,
......@@ -103,10 +107,7 @@ import {
103107import { ProposedPlanCard } from "./ProposedPlanCard";
104108import { ChangedFilesCard } from "./ChangedFilesTree";
105109import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
106import {
107 CHAT_TIMELINE_ANCHOR_OFFSET,
108 keepTimelineEndVisibleAfterOverlayGrowth,
109} from "./timelineScrollAnchoring";
110import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring";
110111import { MessageCopyButton } from "./MessageCopyButton";
111112import { PierreEntryIcon } from "./PierreEntryIcon";
112113import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
......@@ -255,16 +256,27 @@ function TimelineLoadEarlierHeader({
255256 </div>
256257 );
257258}
258const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;
259function 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}
259267const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
260268const TIMELINE_MAINTAIN_SCROLL_AT_END = {
261269 animated: false,
262270 on: {
263271 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,
264276 itemLayout: true,
265277 layout: true,
266278 },
267} as const;
279} as const satisfies MaintainScrollAtEndOptions;
268280
269281// ---------------------------------------------------------------------------
270282// Props (public API)
......@@ -381,8 +393,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
381393 const disclosureAnchorKeyRef = useRef<string | null>(null);
382394 const disclosureSettleFrameRef = useRef<number | null>(null);
383395 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
384 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment);
385
386396 useEffect(() => {
387397 return () => {
388398 if (disclosureSettleFrameRef.current !== null) {
......@@ -533,15 +543,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
533543 onExpandTurn: expandCitedTurn,
534544 onManualNavigation,
535545 });
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]);
545546 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
546547 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
547548 const handleAnchorReady = useCallback(
......@@ -561,6 +562,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
561562 );
562563 return config ? { ...config, onReady: handleAnchorReady } : undefined;
563564 }, [anchorMessageId, handleAnchorReady, rows]);
565 const timelineListFooter = useMemo(
566 () => <TimelineListFooter composerInset={anchoredEndSpace ? 0 : contentInsetEndAdjustment} />,
567 [anchoredEndSpace, contentInsetEndAdjustment],
568 );
564569
565570 const handleScroll = useCallback(() => {
566571 const state = listRef.current?.getState?.();
......@@ -736,7 +741,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
736741 {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})}
737742 onLoad={onCitationListLoad}
738743 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
739 contentInsetEndAdjustment={contentInsetEndAdjustment}
744 contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0}
740745 maintainScrollAtEnd={
741746 citationPositioning ||
742747 anchoredEndSpace ||
......@@ -748,6 +753,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
748753 maintainVisibleContentPosition={
749754 citationPositioning ? false : maintainVisibleContentPosition
750755 }
756 maintainScrollAtEndThreshold={1}
751757 onScroll={handleScroll}
752758 className={cn(
753759 "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({
766772 TIMELINE_LIST_HEADER
767773 )
768774 }
769 ListFooterComponent={TIMELINE_LIST_FOOTER}
775 ListFooterComponent={timelineListFooter}
770776 />
771777 <TimelineMinimap
772778 items={minimapItems}
apps/web/src/components/chat/ProviderModelPicker.test.tsx+20
......@@ -99,4 +99,24 @@ describe("ProviderModelPicker", () => {
9999 expect(markup).toContain("Fallback model");
100100 expect(markup).not.toContain(">missing-model<");
101101 });
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 });
102122});
apps/web/src/components/chat/ProviderModelPicker.tsx+28-8
......@@ -18,7 +18,12 @@ import {
1818 getTriggerDisplayModelName,
1919} from "./providerIconUtils";
2020import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances";
21import { ComposerControl, ComposerControlChevron } from "./ComposerControl";
21import {
22 ComposerControl,
23 ComposerControlChevron,
24 type ComposerControlSize,
25} from "./ComposerControl";
26import { composerFloatingLayerProps } from "./composerEventScope";
2227
2328export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
2429 /**
......@@ -34,7 +39,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
3439 keybindings?: ResolvedKeybindingsConfig;
3540 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>;
3641 activeProviderIconClassName?: string;
42 instanceIndicatorBackground?: string;
43 size?: ComposerControlSize;
3744 compact?: boolean;
45 isComposerOwned?: boolean;
3846 disabled?: boolean;
3947 terminalOpen?: boolean;
4048 open?: boolean;
......@@ -47,6 +55,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
4755}) {
4856 const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false);
4957 const isMenuOpen = props.open ?? uncontrolledIsMenuOpen;
58 const size = props.size ?? "sm";
5059
5160 // Resolve the active instance entry by exact routing key. The composer
5261 // resolves fallbacks before rendering this component; if the selected
......@@ -148,6 +157,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
148157 <ComposerControl
149158 aria-label={props.triggerAriaLabel}
150159 variant={props.triggerVariant ?? "ghost"}
160 size={size}
151161 data-chat-provider-model-picker="true"
152162 className={cn(
153163 "min-w-0 justify-between whitespace-nowrap",
......@@ -158,7 +168,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
158168 />
159169 }
160170 >
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 >
162174 {activeEntry ? (
163175 <ProviderInstanceIcon
164176 driverKind={activeEntry.driverKind}
......@@ -167,15 +179,22 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
167179 showBadge={showInstanceBadge}
168180 className="size-4"
169181 iconClassName={cn("size-4", props.activeProviderIconClassName)}
170 indicatorBackground="var(--contrast-input)"
182 indicatorBackground={props.instanceIndicatorBackground ?? "var(--contrast-input)"}
171183 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",
174186 )}
175187 />
176188 ) : null}
177189 <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 >
179198 {triggerTitle}
180199 </TooltipTrigger>
181200 <TooltipPopup side="top">{triggerLabel}</TooltipPopup>
......@@ -187,13 +206,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
187206 ) : null}
188207 </span>
189208 <span aria-hidden="true" className="flex items-center">
190 <ComposerControlChevron />
209 <ComposerControlChevron size={size} />
191210 </span>
192211 </PopoverTrigger>
193212 <PopoverPopup
213 {...(props.isComposerOwned ? composerFloatingLayerProps : {})}
194214 align="start"
195215 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))]"
197217 >
198218 <ModelPickerContent
199219 activeInstanceId={activeInstanceId}
apps/web/src/components/chat/TraitsPicker.tsx+25-6
......@@ -32,7 +32,13 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore";
3232import { getProviderModelCapabilities } from "../../providerModels";
3333import { cn } from "~/lib/utils";
3434import { Badge } from "../ui/badge";
35import { ComposerControl, ComposerControlChevron, ComposerControlIcon } from "./ComposerControl";
35import {
36 ComposerControl,
37 ComposerControlChevron,
38 ComposerControlIcon,
39 type ComposerControlSize,
40} from "./ComposerControl";
41import { composerFloatingLayerProps } from "./composerEventScope";
3642
3743type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;
3844
......@@ -274,6 +280,7 @@ export interface TraitsMenuContentProps {
274280 planModeEnabled: boolean;
275281 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
276282 triggerClassName?: string;
283 isComposerOwned?: boolean;
277284}
278285
279286export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
......@@ -540,8 +547,13 @@ export const TraitsPicker = memo(function TraitsPicker({
540547 planModeEnabled,
541548 triggerVariant,
542549 triggerClassName,
550 isComposerOwned,
551 size = "sm",
543552 ...persistence
544}: TraitsMenuContentProps & TraitsPersistence) {
553}: TraitsMenuContentProps &
554 TraitsPersistence & {
555 size?: ComposerControlSize;
556 }) {
545557 const [isMenuOpen, setIsMenuOpen] = useState(false);
546558 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =
547559 getTraitsSectionVisibility({
......@@ -577,6 +589,7 @@ export const TraitsPicker = memo(function TraitsPicker({
577589 <>
578590 <ComposerControlIcon
579591 icon={ZapIcon}
592 size={size}
580593 className={cn(
581594 "fill-current opacity-80",
582595 provider === "claudeAgent" ? "text-[#d97757]" : "text-foreground",
......@@ -599,6 +612,7 @@ export const TraitsPicker = memo(function TraitsPicker({
599612 render={
600613 <ComposerControl
601614 variant={triggerVariant ?? "ghost"}
615 size={size}
602616 className={cn(
603617 isCodexStyle
604618 ? "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({
609623 }
610624 >
611625 {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 >
613632 {fastModeIcon}
614633 <span className="min-w-0 truncate">{triggerLabel}</span>
615 <ComposerControlChevron />
634 <ComposerControlChevron size={size} />
616635 </span>
617636 ) : (
618637 <>
619638 {fastModeIcon}
620639 <span>{triggerLabel}</span>
621 <ComposerControlChevron />
640 <ComposerControlChevron size={size} />
622641 </>
623642 )}
624643 </MenuTrigger>
625 <MenuPopup align="start">
644 <MenuPopup align="start" {...(isComposerOwned ? composerFloatingLayerProps : {})}>
626645 <TraitsMenuContent
627646 provider={provider}
628647 {...(instanceId ? { instanceId } : {})}
apps/web/src/components/chat/composerEventScope.test.ts created+61
......@@ -0,0 +1,61 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 isInsideComposerFloatingLayer,
5 isInsideRestingComposerControlScope,
6} from "./composerEventScope";
7
8class 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
19afterEach(() => {
20 vi.unstubAllGlobals();
21});
22
23describe("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 @@
1const COMPOSER_FLOATING_LAYER_SELECTOR = [
2 '[data-composer-drawer-layer="true"]',
3 '[data-chat-composer-floating-layer="true"]',
4].join(",");
5
6export const composerFloatingLayerProps = {
7 "data-chat-composer-floating-layer": "true",
8} as const;
9
10export function isInsideComposerFloatingLayer(target: EventTarget | null): boolean {
11 return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null;
12}
13
14export 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 {
1212 isClaudeUltrathinkPrompt,
1313 normalizeModelSlug,
1414} from "@t3tools/shared/model";
15import type { VariantProps } from "class-variance-authority";
1516import type { ReactNode } from "react";
1617
18import type { buttonVariants } from "../ui/button";
1719import type { DraftId } from "../../composerDraftStore";
1820import { getProviderModelCapabilities } from "../../providerModels";
21import type { ComposerControlSize } from "./ComposerControl";
1922import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
2023
2124export type ComposerProviderStateInput = {
......@@ -49,6 +52,10 @@ type TraitsRenderInput = {
4952 prompt: string;
5053 onPromptChange: (prompt: string) => void;
5154 planModeEnabled: boolean;
55 size?: ComposerControlSize;
56 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
57 triggerClassName?: string;
58 isComposerOwned?: boolean;
5259};
5360
5461export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState {
......@@ -123,6 +130,10 @@ function renderTraitsControl(
123130 prompt,
124131 onPromptChange,
125132 planModeEnabled,
133 size,
134 triggerVariant,
135 triggerClassName,
136 isComposerOwned,
126137 } = input;
127138 const hasTarget = threadRef !== undefined || draftId !== undefined;
128139 if (
......@@ -150,6 +161,10 @@ function renderTraitsControl(
150161 prompt={prompt}
151162 onPromptChange={onPromptChange}
152163 planModeEnabled={planModeEnabled}
164 {...(size !== undefined ? { size } : {})}
165 {...(triggerVariant !== undefined ? { triggerVariant } : {})}
166 {...(triggerClassName !== undefined ? { triggerClassName } : {})}
167 {...(isComposerOwned ? { isComposerOwned } : {})}
153168 />
154169 );
155170}
apps/web/src/components/chat/composerScrollGesture.test.ts created+94
......@@ -0,0 +1,94 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 createComposerScrollGestureState,
5 recordComposerScrollGestureEvent,
6 resetComposerScrollGesture,
7 suppressActiveComposerScrollGesture,
8} from "./composerScrollGesture";
9
10const RESET_MS = 120;
11const THRESHOLD_PX = 24;
12
13function 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
33describe("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 @@
1export type ComposerScrollGestureState = {
2 accumulatedDeltaPx: number;
3 collapseSuppressed: boolean;
4 lastEventAt: number;
5};
6
7export function createComposerScrollGestureState(): ComposerScrollGestureState {
8 return {
9 accumulatedDeltaPx: 0,
10 collapseSuppressed: false,
11 lastEventAt: Number.NEGATIVE_INFINITY,
12 };
13}
14
15export function resetComposerScrollGesture(state: ComposerScrollGestureState): void {
16 state.accumulatedDeltaPx = 0;
17 state.collapseSuppressed = false;
18 state.lastEventAt = Number.NEGATIVE_INFINITY;
19}
20
21export 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
31export 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 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2import {
3 getAnchoredTurnMetrics,
4 getRowBottom,
5 keepTimelineEndVisibleAfterOverlayGrowth,
6} from "./timelineScrollAnchoring";
1import { describe, expect, it } from "vite-plus/test";
2import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring";
73
84function buildState({
95 positions,
......@@ -26,33 +22,6 @@ function buildState({
2622}
2723
2824describe("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
5625 it("measures row bottoms from LegendList row position and size", () => {
5726 const state = buildState({
5827 positions: [0, 120],
apps/web/src/components/chat/timelineScrollAnchoring.ts-16
......@@ -22,22 +22,6 @@ export interface AnchoredTurnMetrics {
2222 readonly scrollDeltaToRevealEnd: number;
2323}
2424
25export 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
4125export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null {
4226 const top = state.positionAtIndex(index);
4327 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";
33import {
44 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
55 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
6 getRestingComposerImagePreviewCounts,
7 resolveRestingComposerControlsLayout,
8 shouldAnimateComposerRestingTransition,
69 shouldUseCompactComposerPrimaryActions,
710 shouldUseCompactComposerFooter,
11 shouldUseRestingComposerLayout,
812} from "./composerFooterLayout";
913
14describe("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
1031describe("shouldUseCompactComposerFooter", () => {
1132 it("stays expanded without a measured width", () => {
1233 expect(shouldUseCompactComposerFooter(null)).toBe(false);
......@@ -50,3 +71,145 @@ describe("shouldUseCompactComposerPrimaryActions", () => {
5071 ).toBe(false);
5172 });
5273});
74
75describe("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
104describe("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
133describe("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 @@
11export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620;
22export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780;
3export const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3;
4
5export 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}
315
416export function shouldUseCompactComposerFooter(
517 width: number | null,
......@@ -11,6 +23,36 @@ export function shouldUseCompactComposerFooter(
1123 return width !== null && width < breakpoint;
1224}
1325
26export 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
48export function shouldAnimateComposerRestingTransition(input: {
49 hasCompletedInitialLayout: boolean;
50 stateChanged: boolean;
51 hasInterruptedAnimation: boolean;
52}): boolean {
53 return input.hasCompletedInitialLayout && (input.stateChanged || input.hasInterruptedAnimation);
54}
55
1456export function shouldUseCompactComposerPrimaryActions(
1557 width: number | null,
1658 options?: { hasWideActions?: boolean },
......@@ -20,3 +62,38 @@ export function shouldUseCompactComposerPrimaryActions(
2062 }
2163 return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX;
2264}
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 */
74export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { didComposerSelectionChangeVisibly } from "./composerSelection";
4
5describe("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 @@
1export type ComposerSelectionRange = {
2 start: number;
3 end: number;
4};
5
6export 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
5151the composer or paste them into a message. On iOS, selecting them from **Photo Library** also
5252converts them to JPEG. The 10 MB image limit applies to the converted photo.
5353
54On web and desktop, an existing thread settles its composer into a single-line resting state when
55the composer loses focus. At wider sizes, scrolling the conversation also rests a focused composer,
56except when scrolling toward the end while already there. When the thread-context strip has room,
57the model and mode controls stay available beside the thread context; otherwise they return when the
58composer is focused. Focus the composer or start typing to expand it again. New-thread layouts keep
59the full composer.
60
61At phone-sized web or desktop window widths, existing threads animate between their compact and
62expanded layouts. Up to three image attachments remain visible in either resting layout, followed
63by a count when more are attached. At wider sizes, videos, files, and other draft context remain
64visible at their natural height; the phone-sized compact row reveals those details when expanded.
65
5466On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic,
5567GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
5668when starting a thread or changing an existing thread's model.