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", () => {...@@ -430,26 +430,40 @@ describe("shouldShowComposerContextStrip", () => {
430 hasActiveProject: true,430 hasActiveProject: true,
431 isGitRepo: false,431 isGitRepo: false,
432 showEnvironmentIndicator: true,432 showEnvironmentIndicator: true,
433 hostsRestingComposerControls: false,
433 }),434 }),
434 ).toBe(true);435 ).toBe(true);
435 });436 });
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", () => {
438 expect(439 expect(
439 shouldShowComposerContextStrip({440 shouldShowComposerContextStrip({
440 hasActiveProject: true,441 hasActiveProject: true,
441 isGitRepo: false,442 isGitRepo: false,
442 showEnvironmentIndicator: false,443 showEnvironmentIndicator: false,
444 hostsRestingComposerControls: false,
443 }),445 }),
444 ).toBe(false);446 ).toBe(false);
445 });447 });
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
447 it("shows Git controls without requiring an environment indicator", () => {460 it("shows Git controls without requiring an environment indicator", () => {
448 expect(461 expect(
449 shouldShowComposerContextStrip({462 shouldShowComposerContextStrip({
450 hasActiveProject: true,463 hasActiveProject: true,
451 isGitRepo: true,464 isGitRepo: true,
452 showEnvironmentIndicator: false,465 showEnvironmentIndicator: false,
466 hostsRestingComposerControls: false,
453 }),467 }),
454 ).toBe(true);468 ).toBe(true);
455 });469 });
apps/web/src/components/BranchToolbar.logic.ts+6-1
...@@ -59,8 +59,13 @@ export function shouldShowComposerContextStrip(input: {...@@ -59,8 +59,13 @@ export function shouldShowComposerContextStrip(input: {
59 hasActiveProject: boolean;59 hasActiveProject: boolean;
60 isGitRepo: boolean;60 isGitRepo: boolean;
61 showEnvironmentIndicator: boolean;61 showEnvironmentIndicator: boolean;
62 /** A collapsed composer's controls currently fit in their measured strip host. */
63 hostsRestingComposerControls: boolean;
62}): boolean {64}): boolean {
63 return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator);65 return (
66 input.hasActiveProject &&
67 (input.isGitRepo || input.showEnvironmentIndicator || input.hostsRestingComposerControls)
68 );
64}69}
6570
66export function resolveEnvModeLabel(mode: EnvMode): string {71export function resolveEnvModeLabel(mode: EnvMode): string {
apps/web/src/components/BranchToolbar.tsx+82-30
...@@ -12,7 +12,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat...@@ -12,7 +12,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
12import { useComposerDraftStore, type DraftId } from "../composerDraftStore";12import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
13import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";13import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
14import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";14import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
15import { useIsMobile } from "../hooks/useMediaQuery";
16import {15import {
17 type EnvMode,16 type EnvMode,
18 type EnvironmentOption,17 type EnvironmentOption,
...@@ -40,6 +39,7 @@ import {...@@ -40,6 +39,7 @@ import {
40} from "./ui/menu";39} from "./ui/menu";
41import { Separator } from "./ui/separator";40import { Separator } from "./ui/separator";
42import { ComposerSurface } from "./chat/ComposerSurface";41import { ComposerSurface } from "./chat/ComposerSurface";
42import { cn } from "~/lib/utils";
4343
44interface BranchToolbarProps {44interface BranchToolbarProps {
45 environmentId: EnvironmentId;45 environmentId: EnvironmentId;
...@@ -57,6 +57,8 @@ interface BranchToolbarProps {...@@ -57,6 +57,8 @@ interface BranchToolbarProps {
57 onComposerFocusRequest?: () => void;57 onComposerFocusRequest?: () => void;
58 availableEnvironments?: readonly EnvironmentOption[];58 availableEnvironments?: readonly EnvironmentOption[];
59 onEnvironmentChange?: (environmentId: EnvironmentId) => void;59 onEnvironmentChange?: (environmentId: EnvironmentId) => void;
60 composerControlsHostRef?: (element: HTMLDivElement | null) => void;
61 contextStripVisible?: boolean;
60}62}
6163
62interface MobileRunContextSelectorProps {64interface MobileRunContextSelectorProps {
...@@ -120,15 +122,26 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -120,15 +122,26 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
120 const triggerContent = (122 const triggerContent = (
121 <>123 <>
122 {icon}124 {icon}
123 <span className="min-w-0 truncate">125 <span
124 {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel}126 data-composer-label
127 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"
128 >
129 <span
130 data-composer-label-motion
131 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"
132 >
133 {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel}
134 </span>
125 </span>135 </span>
126 </>136 </>
127 );137 );
128138
129 if (isLocked) {139 if (isLocked) {
130 return (140 return (
131 <span className="inline-flex h-7 min-w-0 max-w-[48%] flex-1 items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 md:hidden">141 <span
142 className="inline-flex h-7 min-w-0 max-w-[48%] flex-initial items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6"
143 data-composer-context-control
144 >
132 {triggerContent}145 {triggerContent}
133 </span>146 </span>
134 );147 );
...@@ -138,7 +151,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -138,7 +151,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
138 <Menu>151 <Menu>
139 <MenuTrigger152 <MenuTrigger
140 render={<Button variant="ghost" size="xs" />}153 render={<Button variant="ghost" size="xs" />}
141 className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden"154 className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80"
155 data-composer-context-control
142 >156 >
143 {triggerContent}157 {triggerContent}
144 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />158 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
...@@ -251,10 +265,14 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -251,10 +265,14 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
251 let counted = 0;265 let counted = 0;
252 for (const child of parent.children) {266 for (const child of parent.children) {
253 if (!(child instanceof HTMLElement)) continue;267 if (!(child instanceof HTMLElement)) continue;
254 if (child.offsetWidth <= 1) continue;268 if (child.offsetWidth === 0) continue;
255 const position = getComputedStyle(child).position;269 const style = getComputedStyle(child);
270 const position = style.position;
256 if (position === "absolute" || position === "fixed") continue;271 if (position === "absolute" || position === "fixed") continue;
257 width += child.offsetWidth;272 width +=
273 child.offsetWidth +
274 (Number.parseFloat(style.marginInlineStart) || 0) +
275 (Number.parseFloat(style.marginInlineEnd) || 0);
258 counted += 1;276 counted += 1;
259 }277 }
260 return width + gap * Math.max(0, counted - 1);278 return width + gap * Math.max(0, counted - 1);
...@@ -264,10 +282,15 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -264,10 +282,15 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
264 let groups = 0;282 let groups = 0;
265 for (const child of current.children) {283 for (const child of current.children) {
266 if (!(child instanceof HTMLElement)) continue;284 if (!(child instanceof HTMLElement)) continue;
267 const width = contentWidth(child);285 // The host itself flexes into all remaining room. Measure the controls
286 // inside it so Git labels compact before squeezing out the model picker.
287 const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]')
288 ? child.querySelector<HTMLElement>('[data-chat-composer-resting-controls="true"]')
289 : null;
290 const width = hostedControls ? contentWidth(hostedControls) : contentWidth(child);
268 if (width <= 1) continue;291 if (width <= 1) continue;
269 needed += width;
270 groups += 1;292 groups += 1;
293 needed += width;
271 }294 }
272 needed += stripGap * Math.max(0, groups - 1);295 needed += stripGap * Math.max(0, groups - 1);
273 for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) {296 for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) {
...@@ -390,6 +413,8 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -390,6 +413,8 @@ export const BranchToolbar = memo(function BranchToolbar({
390 onComposerFocusRequest,413 onComposerFocusRequest,
391 availableEnvironments,414 availableEnvironments,
392 onEnvironmentChange,415 onEnvironmentChange,
416 composerControlsHostRef,
417 contextStripVisible = true,
393}: BranchToolbarProps) {418}: BranchToolbarProps) {
394 const threadRef = useMemo(419 const threadRef = useMemo(
395 () => scopeThreadRef(environmentId, threadId),420 () => scopeThreadRef(environmentId, threadId),
...@@ -460,7 +485,6 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -460,7 +485,6 @@ export const BranchToolbar = memo(function BranchToolbar({
460 activeEnvironment: activeEnvironmentOption,485 activeEnvironment: activeEnvironmentOption,
461 canPickEnvironment: showEnvironmentPicker,486 canPickEnvironment: showEnvironmentPicker,
462 });487 });
463 const isMobile = useIsMobile();
464 const [stripElement, setStripElement] = useState<HTMLDivElement | null>(null);488 const [stripElement, setStripElement] = useState<HTMLDivElement | null>(null);
465 const labelsOverflow = useLabelsOverflow(stripElement);489 const labelsOverflow = useLabelsOverflow(stripElement);
466490
...@@ -470,24 +494,40 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -470,24 +494,40 @@ export const BranchToolbar = memo(function BranchToolbar({
470 <ComposerSurface.ContextStrip494 <ComposerSurface.ContextStrip
471 ref={setStripElement}495 ref={setStripElement}
472 data-compact={labelsOverflow ? "" : undefined}496 data-compact={labelsOverflow ? "" : undefined}
497 className={cn(
498 "gap-1 text-xs font-normal text-muted-foreground/70",
499 // A non-Git strip with no visible composer controls should occupy no
500 // space, but its host must retain a prospective width so controls can
501 // become visible again when the chat view grows.
502 !contextStripVisible && "pointer-events-none invisible absolute inset-x-0 top-full",
503 )}
473 >504 >
474 {isMobile && showGitControls ? (505 {showGitControls ? (
475 <MobileRunContextSelector506 <div className="contents @3xl/composer-surface:hidden">
476 envLocked={envLocked}507 <MobileRunContextSelector
477 envModeLocked={envModeLocked}508 envLocked={envLocked}
478 environmentId={environmentId}509 envModeLocked={envModeLocked}
479 availableEnvironments={availableEnvironments}510 environmentId={environmentId}
480 showEnvironmentPicker={showEnvironmentPicker}511 availableEnvironments={availableEnvironments}
481 showEnvironmentIndicator={showEnvironmentIndicator}512 showEnvironmentPicker={showEnvironmentPicker}
482 onEnvironmentChange={onEnvironmentChange}513 showEnvironmentIndicator={showEnvironmentIndicator}
483 effectiveEnvMode={effectiveEnvMode}514 onEnvironmentChange={onEnvironmentChange}
484 activeWorktreePath={activeWorktreePath}515 effectiveEnvMode={effectiveEnvMode}
485 onEnvModeChange={onEnvModeChange}516 activeWorktreePath={activeWorktreePath}
486 previousWorktreeLabel={previousWorktreeLabel}517 onEnvModeChange={onEnvModeChange}
487 onUsePreviousWorktree={onUsePreviousWorktree}518 previousWorktreeLabel={previousWorktreeLabel}
488 />519 onUsePreviousWorktree={onUsePreviousWorktree}
489 ) : (520 />
490 <div className="flex min-w-10 flex-1 items-center gap-1">521 </div>
522 ) : null}
523 {showGitControls || showEnvironmentIndicator ? (
524 <div
525 className={cn(
526 "min-h-7 min-w-10 items-center gap-1 sm:min-h-6",
527 showGitControls ? "hidden @3xl/composer-surface:flex" : "flex",
528 composerControlsHostRef ? "shrink" : "flex-1",
529 )}
530 >
491 {showEnvironmentIndicator && availableEnvironments && (531 {showEnvironmentIndicator && availableEnvironments && (
492 <>532 <>
493 <BranchToolbarEnvironmentSelector533 <BranchToolbarEnvironmentSelector
...@@ -516,11 +556,23 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -516,11 +556,23 @@ export const BranchToolbar = memo(function BranchToolbar({
516 />556 />
517 ) : null}557 ) : null}
518 </div>558 </div>
519 )}559 ) : null}
560
561 {composerControlsHostRef ? (
562 // The host takes whatever the workspace and branch controls leave
563 // over, in both strip layouts, so a collapsed composer can show its
564 // model and mode controls wherever they fit.
565 <div
566 ref={composerControlsHostRef}
567 data-composer-context-control
568 data-chat-resting-composer-controls-host="true"
569 className="flex min-w-0 flex-1 items-center justify-start overflow-x-clip overflow-y-visible"
570 />
571 ) : null}
520572
521 {showGitControls ? (573 {showGitControls ? (
522 <BranchToolbarBranchSelector574 <BranchToolbarBranchSelector
523 className="min-w-0 flex-1 justify-end md:ml-auto md:flex-initial"575 className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto"
524 environmentId={environmentId}576 environmentId={environmentId}
525 threadId={threadId}577 threadId={threadId}
526 {...(draftId ? { draftId } : {})}578 {...(draftId ? { draftId } : {})}
apps/web/src/components/BranchToolbarBranchSelector.tsx+12-2
...@@ -745,7 +745,17 @@ export function BranchToolbarBranchSelector({...@@ -745,7 +745,17 @@ export function BranchToolbarBranchSelector({
745 }745 }
746 >746 >
747 <ChangeRequestStatusIcon className="size-3" />747 <ChangeRequestStatusIcon className="size-3" />
748 <span>#{branchPr.number}</span>748 <span
749 data-composer-label
750 className="min-w-0 max-w-12 overflow-hidden group-data-[compact]/composer-context:max-w-0"
751 >
752 <span
753 data-composer-label-motion
754 className="block w-full min-w-0 max-w-12 origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"
755 >
756 #{branchPr.number}
757 </span>
758 </span>
749 </TooltipTrigger>759 </TooltipTrigger>
750 <TooltipPopup side="top">{branchPrTooltip}</TooltipPopup>760 <TooltipPopup side="top">{branchPrTooltip}</TooltipPopup>
751 </Tooltip>761 </Tooltip>
...@@ -759,7 +769,7 @@ export function BranchToolbarBranchSelector({...@@ -759,7 +769,7 @@ export function BranchToolbarBranchSelector({
759 >769 >
760 <ComboboxTrigger770 <ComboboxTrigger
761 render={<Button variant="ghost" size="xs" />}771 render={<Button variant="ghost" size="xs" />}
762 className="min-w-0 max-w-full text-muted-foreground/70 hover:text-foreground/80"772 className="min-w-0 max-w-full font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80"
763 disabled={isInitialBranchesLoadPending || isBranchActionPending}773 disabled={isInitialBranchesLoadPending || isBranchActionPending}
764 >774 >
765 <GitBranchIcon className="size-3 shrink-0 opacity-70" />775 <GitBranchIcon className="size-3 shrink-0 opacity-70" />
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+2-2
...@@ -51,7 +51,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -51,7 +51,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
51 if (envLocked) {51 if (envLocked) {
52 return (52 return (
53 <span53 <span
54 className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs"54 className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6"
55 data-composer-context-control55 data-composer-context-control
56 >56 >
57 {activeWorktreePath ? (57 {activeWorktreePath ? (
...@@ -90,7 +90,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -90,7 +90,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
90 <SelectTrigger90 <SelectTrigger
91 variant="ghost"91 variant="ghost"
92 size="xs"92 size="xs"
93 className="min-w-0 shrink font-medium"93 className="min-w-0 shrink font-normal text-xs!"
94 aria-label="Workspace"94 aria-label="Workspace"
95 data-composer-context-control95 data-composer-context-control
96 >96 >
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+2-2
...@@ -49,7 +49,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -49,7 +49,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
49 if (envLocked || onEnvironmentChange === undefined) {49 if (envLocked || onEnvironmentChange === undefined) {
50 return (50 return (
51 <span51 <span
52 className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs"52 className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6"
53 data-composer-context-control53 data-composer-context-control
54 >54 >
55 <EnvironmentMachineIcon55 <EnvironmentMachineIcon
...@@ -81,7 +81,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -81,7 +81,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
81 <SelectTrigger81 <SelectTrigger
82 variant="ghost"82 variant="ghost"
83 size="xs"83 size="xs"
84 className="min-w-0 max-w-full font-medium"84 className="min-w-0 max-w-full font-normal text-xs!"
85 aria-label="Run on"85 aria-label="Run on"
86 data-composer-context-control86 data-composer-context-control
87 >87 >
apps/web/src/components/ChatView.tsx+70-11
...@@ -1491,6 +1491,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1491,6 +1491,9 @@ function ChatViewContent(props: ChatViewProps) {
1491 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);1491 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);
1492 const localComposerRef = useRef<ChatComposerHandle | null>(null);1492 const localComposerRef = useRef<ChatComposerHandle | null>(null);
1493 const composerRef = useComposerHandleContext() ?? localComposerRef;1493 const composerRef = useComposerHandleContext() ?? localComposerRef;
1494 const [restingComposerControlsHost, setRestingComposerControlsHost] =
1495 useState<HTMLDivElement | null>(null);
1496 const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false);
1494 const citeAssistantText = useCallback(1497 const citeAssistantText = useCallback(
1495 (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => {1498 (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => {
1496 const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false;1499 const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false;
...@@ -1581,10 +1584,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1581,10 +1584,16 @@ function ChatViewContent(props: ChatViewProps) {
1581 LastInvokedScriptByProjectSchema,1584 LastInvokedScriptByProjectSchema,
1582 );1585 );
1583 const legendListRef = useRef<LegendListRef | null>(null);1586 const legendListRef = useRef<LegendListRef | null>(null);
1587 const getTimelineScrollableNode = useCallback(
1588 () => legendListRef.current?.getScrollableNode() ?? null,
1589 [],
1590 );
1584 const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null);1591 const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null);
1585 const [composerOverlayHeight, setComposerOverlayHeight] = useState(0);1592 const [composerOverlayHeight, setComposerOverlayHeight] = useState(0);
1593 const composerOverlayHeightRef = useRef(0);
1586 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);1594 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
1587 const isAtEndRef = useRef(true);1595 const isAtEndRef = useRef(true);
1596 const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []);
1588 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});1597 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
1589 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});1598 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
1590 const sendInFlightRef = useRef(false);1599 const sendInFlightRef = useRef(false);
...@@ -3000,10 +3009,20 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3000,10 +3009,20 @@ function ChatViewContent(props: ChatViewProps) {
3000 terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;3009 terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;
3001 // Default true while loading to avoid toolbar flicker.3010 // Default true while loading to avoid toolbar flicker.
3002 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;3011 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
3012 // Keep a hidden, off-flow strip mounted for existing threads so the composer
3013 // can measure whether its relocated controls fit. The visible chrome remains
3014 // content-driven: Git/environment context or controls that actually fit.
3015 const mountComposerContextStrip = shouldShowComposerContextStrip({
3016 hasActiveProject: activeProject !== null,
3017 isGitRepo,
3018 showEnvironmentIndicator: showComposerEnvironmentIndicator,
3019 hostsRestingComposerControls: routeKind === "server",
3020 });
3003 const showComposerContextStrip = shouldShowComposerContextStrip({3021 const showComposerContextStrip = shouldShowComposerContextStrip({
3004 hasActiveProject: activeProject !== null,3022 hasActiveProject: activeProject !== null,
3005 isGitRepo,3023 isGitRepo,
3006 showEnvironmentIndicator: showComposerEnvironmentIndicator,3024 showEnvironmentIndicator: showComposerEnvironmentIndicator,
3025 hostsRestingComposerControls: routeKind === "server" && restingComposerControlsVisible,
3007 });3026 });
3008 const initialDiffPanelGitScope =3027 const initialDiffPanelGitScope =
3009 gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch";3028 gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch";
...@@ -4336,6 +4355,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4336,6 +4355,7 @@ function ChatViewContent(props: ChatViewProps) {
4336 const showScrollDebouncer = useRef(4355 const showScrollDebouncer = useRef(
4337 new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),4356 new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
4338 );4357 );
4358 const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null);
4339 const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end");4359 const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end");
4340 // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd4360 // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd
4341 // re-pins on its own (independent of the refs), so the timeline needs a4361 // re-pins on its own (independent of the refs), so the timeline needs a
...@@ -4520,6 +4540,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4520,6 +4540,14 @@ function ChatViewContent(props: ChatViewProps) {
4520 // Only an upward wheel is a navigation intent; wheeling down while4540 // Only an upward wheel is a navigation intent; wheeling down while
4521 // following either does nothing (at the end) or moves toward it.4541 // following either does nothing (at the end) or moves toward it.
4522 const handleWheel = (event: WheelEvent) => {4542 const handleWheel = (event: WheelEvent) => {
4543 if (event.deltaY > 0) {
4544 timelineScrollIntentRef.current = "toward-end";
4545 if (isAtEndRef.current) {
4546 composerRef.current?.restoreAfterTimelineReachedEnd();
4547 }
4548 } else if (event.deltaY < 0) {
4549 timelineScrollIntentRef.current = "away-from-end";
4550 }
4523 if (4551 if (
4524 event.deltaY < 0 &&4552 event.deltaY < 0 &&
4525 contentScrollsUp() &&4553 contentScrollsUp() &&
...@@ -4561,10 +4589,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4561,10 +4589,16 @@ function ChatViewContent(props: ChatViewProps) {
4561 case "PageUp":4589 case "PageUp":
4562 case "Home":4590 case "Home":
4563 case "ArrowUp":4591 case "ArrowUp":
4592 timelineScrollIntentRef.current = "away-from-end";
4564 if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) {4593 if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) {
4565 handleManualNavigation();4594 handleManualNavigation();
4566 }4595 }
4567 break;4596 break;
4597 case "PageDown":
4598 case "End":
4599 case "ArrowDown":
4600 timelineScrollIntentRef.current = "toward-end";
4601 break;
4568 default:4602 default:
4569 break;4603 break;
4570 }4604 }
...@@ -4655,6 +4689,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4655,6 +4689,9 @@ function ChatViewContent(props: ChatViewProps) {
4655 if (isAtEndRef.current === isAtEnd) return;4689 if (isAtEndRef.current === isAtEnd) return;
4656 isAtEndRef.current = isAtEnd;4690 isAtEndRef.current = isAtEnd;
4657 if (isAtEnd) {4691 if (isAtEnd) {
4692 if (timelineScrollIntentRef.current === "toward-end") {
4693 composerRef.current?.restoreAfterTimelineReachedEnd();
4694 }
4658 timelineScrollModeRef.current = "following-end";4695 timelineScrollModeRef.current = "following-end";
4659 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;4696 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
4660 setTimelineLiveFollowEnabled(true);4697 setTimelineLiveFollowEnabled(true);
...@@ -4727,6 +4764,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4727,6 +4764,7 @@ function ChatViewContent(props: ChatViewProps) {
4727 useEffect(() => {4764 useEffect(() => {
4728 setPullRequestDialogState(null);4765 setPullRequestDialogState(null);
4729 isAtEndRef.current = true;4766 isAtEndRef.current = true;
4767 timelineScrollIntentRef.current = null;
4730 timelineScrollModeRef.current = "following-end";4768 timelineScrollModeRef.current = "following-end";
4731 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;4769 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
4732 setTimelineLiveFollowEnabled(true);4770 setTimelineLiveFollowEnabled(true);
...@@ -4856,18 +4894,25 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4856,18 +4894,25 @@ function ChatViewContent(props: ChatViewProps) {
4856 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId4894 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
4857 ? activePlan.steps4895 ? activePlan.steps
4858 : null;4896 : null;
4897
4898 const publishComposerOverlayHeight = useCallback((height: number) => {
4899 const nextHeight = Math.ceil(height);
4900 if (nextHeight <= 0) return;
4901 const previousHeight = composerOverlayHeightRef.current;
4902 if (previousHeight !== nextHeight) {
4903 composerOverlayHeightRef.current = nextHeight;
4904 setComposerOverlayHeight(nextHeight);
4905 }
4906 setScrollToEndClearance((currentClearance) =>
4907 currentClearance === nextHeight ? currentClearance : nextHeight,
4908 );
4909 }, []);
4910
4859 useLayoutEffect(() => {4911 useLayoutEffect(() => {
4860 if (!composerOverlayElement) return;4912 if (!composerOverlayElement) return;
48614913
4862 const updateHeight = () => {4914 const updateHeight = () => {
4863 const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height);4915 publishComposerOverlayHeight(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 );
4871 };4916 };
48724917
4873 updateHeight();4918 updateHeight();
...@@ -4878,7 +4923,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4878,7 +4923,7 @@ function ChatViewContent(props: ChatViewProps) {
4878 return () => {4923 return () => {
4879 resizeObserver.disconnect();4924 resizeObserver.disconnect();
4880 };4925 };
4881 }, [composerOverlayElement]);4926 }, [composerOverlayElement, publishComposerOverlayHeight]);
4882 const activeThreadPr =4927 const activeThreadPr =
4883 replacementLinkedThreadPullRequest !== null4928 replacementLinkedThreadPullRequest !== null
4884 ? (gitStatusQuery.data?.pr ?? null)4929 ? (gitStatusQuery.data?.pr ?? null)
...@@ -7638,7 +7683,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7638,7 +7683,11 @@ function ChatViewContent(props: ChatViewProps) {
7638 >7683 >
7639 <Button7684 <Button
7640 aria-label="Scroll to end"7685 aria-label="Scroll to end"
7641 onClick={() => scrollToEnd(true)}7686 onPointerDown={(event) => event.preventDefault()}
7687 onClick={() => {
7688 composerRef.current?.restoreAfterTimelineReachedEnd();
7689 scrollToEnd(true);
7690 }}
7642 className="pointer-events-auto gap-1.5 rounded-full px-3 text-muted-foreground hover:text-foreground"7691 className="pointer-events-auto gap-1.5 rounded-full px-3 text-muted-foreground hover:text-foreground"
7643 size="xs"7692 size="xs"
7644 variant="glass"7693 variant="glass"
...@@ -7756,6 +7805,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7756,6 +7805,14 @@ function ChatViewContent(props: ChatViewProps) {
7756 keybindings={keybindings}7805 keybindings={keybindings}
7757 terminalOpen={Boolean(terminalUiState.terminalOpen)}7806 terminalOpen={Boolean(terminalUiState.terminalOpen)}
7758 gitCwd={gitCwd}7807 gitCwd={gitCwd}
7808 restingControlsHost={restingComposerControlsHost}
7809 restingControlsHaveLeadingContext={
7810 isGitRepo || showComposerEnvironmentIndicator
7811 }
7812 onRestingControlsVisibilityChange={setRestingComposerControlsVisible}
7813 getTimelineScrollableNode={getTimelineScrollableNode}
7814 isTimelineAtLogicalEnd={isTimelineAtLogicalEnd}
7815 onComposerOverlayHeightChange={publishComposerOverlayHeight}
7759 promptRef={promptRef}7816 promptRef={promptRef}
7760 composerImagesRef={composerImagesRef}7817 composerImagesRef={composerImagesRef}
7761 composerFilesRef={composerFilesRef}7818 composerFilesRef={composerFilesRef}
...@@ -7796,7 +7853,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7796,7 +7853,7 @@ function ChatViewContent(props: ChatViewProps) {
7796 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}7853 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}
7797 className="relative z-0"7854 className="relative z-0"
7798 >7855 >
7799 {showComposerContextStrip && (7856 {mountComposerContextStrip && (
7800 <div className="pointer-events-auto">7857 <div className="pointer-events-auto">
7801 <BranchToolbar7858 <BranchToolbar
7802 environmentId={activeThread.environmentId}7859 environmentId={activeThread.environmentId}
...@@ -7823,6 +7880,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7823,6 +7880,8 @@ function ChatViewContent(props: ChatViewProps) {
7823 : {})}7880 : {})}
7824 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}7881 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
7825 availableEnvironments={logicalProjectEnvironments}7882 availableEnvironments={logicalProjectEnvironments}
7883 composerControlsHostRef={setRestingComposerControlsHost}
7884 contextStripVisible={showComposerContextStrip}
7826 />7885 />
7827 </div>7886 </div>
7828 )}7887 )}
apps/web/src/components/ComposerPromptEditor.tsx+61-7
...@@ -87,6 +87,7 @@ import { getTimelinePageScrollKey } from "./chat/pageScrollController";...@@ -87,6 +87,7 @@ import { getTimelinePageScrollKey } from "./chat/pageScrollController";
87import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";87import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
88import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";88import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
89import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";89import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
90import { didComposerSelectionChangeVisibly } from "./composerSelection";
90import {91import {
91 $consumeComposerCitationCommentRequest,92 $consumeComposerCitationCommentRequest,
92 $createComposerCitationNode,93 $createComposerCitationNode,
...@@ -904,7 +905,9 @@ interface ComposerPromptEditorProps {...@@ -904,7 +905,9 @@ interface ComposerPromptEditorProps {
904 skills: ReadonlyArray<ServerProviderSkill>;905 skills: ReadonlyArray<ServerProviderSkill>;
905 disabled: boolean;906 disabled: boolean;
906 placeholder: string;907 placeholder: string;
908 containerClassName?: string;
907 className?: string;909 className?: string;
910 placeholderClassName?: string;
908 onRemoveTerminalContext: (contextId: string) => void;911 onRemoveTerminalContext: (contextId: string) => void;
909 onChange: (912 onChange: (
910 nextValue: string,913 nextValue: string,
...@@ -913,6 +916,7 @@ interface ComposerPromptEditorProps {...@@ -913,6 +916,7 @@ interface ComposerPromptEditorProps {
913 cursorAdjacentToMention: boolean,916 cursorAdjacentToMention: boolean,
914 terminalContextIds: string[],917 terminalContextIds: string[],
915 ) => void;918 ) => void;
919 onVisibleSelectionChange?: () => void;
916 onCommandKeyDown?: (920 onCommandKeyDown?: (
917 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",921 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
918 event: KeyboardEvent,922 event: KeyboardEvent,
...@@ -1557,9 +1561,12 @@ function ComposerPromptEditorInner({...@@ -1557,9 +1561,12 @@ function ComposerPromptEditorInner({
1557 skills,1561 skills,
1558 disabled,1562 disabled,
1559 placeholder,1563 placeholder,
1564 containerClassName,
1560 className,1565 className,
1566 placeholderClassName,
1561 onRemoveTerminalContext,1567 onRemoveTerminalContext,
1562 onChange,1568 onChange,
1569 onVisibleSelectionChange,
1563 onCommandKeyDown,1570 onCommandKeyDown,
1564 onPageScrollKeyDown,1571 onPageScrollKeyDown,
1565 onPageScrollKeyUp,1572 onPageScrollKeyUp,
...@@ -1569,7 +1576,9 @@ function ComposerPromptEditorInner({...@@ -1569,7 +1576,9 @@ function ComposerPromptEditorInner({
1569}: ComposerPromptEditorProps) {1576}: ComposerPromptEditorProps) {
1570 const [editor] = useLexicalComposerContext();1577 const [editor] = useLexicalComposerContext();
1571 const onChangeRef = useRef(onChange);1578 const onChangeRef = useRef(onChange);
1579 const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange);
1572 const initialCursor = clampCollapsedComposerCursor(value, cursor);1580 const initialCursor = clampCollapsedComposerCursor(value, cursor);
1581 const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor);
1573 const terminalContextsSignature = terminalContextSignature(terminalContexts);1582 const terminalContextsSignature = terminalContextSignature(terminalContexts);
1574 const terminalContextsSignatureRef = useRef(terminalContextsSignature);1583 const terminalContextsSignatureRef = useRef(terminalContextsSignature);
1575 const skillsSignature = skillSignature(skills);1584 const skillsSignature = skillSignature(skills);
...@@ -1578,9 +1587,10 @@ function ComposerPromptEditorInner({...@@ -1578,9 +1587,10 @@ function ComposerPromptEditorInner({
1578 const snapshotRef = useRef({1587 const snapshotRef = useRef({
1579 value,1588 value,
1580 cursor: initialCursor,1589 cursor: initialCursor,
1581 expandedCursor: expandCollapsedComposerCursor(value, initialCursor),1590 expandedCursor: initialExpandedCursor,
1582 terminalContextIds: terminalContexts.map((context) => context.id),1591 terminalContextIds: terminalContexts.map((context) => context.id),
1583 });1592 });
1593 const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor });
1584 const isApplyingControlledUpdateRef = useRef(false);1594 const isApplyingControlledUpdateRef = useRef(false);
1585 const citationCommentRequestRef = useRef<ComposerCitationCommentRequest | null>(null);1595 const citationCommentRequestRef = useRef<ComposerCitationCommentRequest | null>(null);
1586 const [openCitationComment, setOpenCitationComment] =1596 const [openCitationComment, setOpenCitationComment] =
...@@ -1605,6 +1615,10 @@ function ComposerPromptEditorInner({...@@ -1605,6 +1615,10 @@ function ComposerPromptEditorInner({
1605 onChangeRef.current = onChange;1615 onChangeRef.current = onChange;
1606 }, [onChange]);1616 }, [onChange]);
16071617
1618 useEffect(() => {
1619 onVisibleSelectionChangeRef.current = onVisibleSelectionChange;
1620 }, [onVisibleSelectionChange]);
1621
1608 useLayoutEffect(() => {1622 useLayoutEffect(() => {
1609 skillMetadataRef.current = skillMetadataByName(skills);1623 skillMetadataRef.current = skillMetadataByName(skills);
1610 }, [skills]);1624 }, [skills]);
...@@ -1643,12 +1657,17 @@ function ComposerPromptEditorInner({...@@ -1643,12 +1657,17 @@ function ComposerPromptEditorInner({
1643 return;1657 return;
1644 }1658 }
16451659
1660 const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor);
1646 snapshotRef.current = {1661 snapshotRef.current = {
1647 value,1662 value,
1648 cursor: normalizedCursor,1663 cursor: normalizedCursor,
1649 expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor),1664 expandedCursor: normalizedExpandedCursor,
1650 terminalContextIds: terminalContexts.map((context) => context.id),1665 terminalContextIds: terminalContexts.map((context) => context.id),
1651 };1666 };
1667 selectionRangeRef.current = {
1668 start: normalizedExpandedCursor,
1669 end: normalizedExpandedCursor,
1670 };
1652 terminalContextsSignatureRef.current = terminalContextsSignature;1671 terminalContextsSignatureRef.current = terminalContextsSignature;
1653 skillsSignatureRef.current = skillsSignature;1672 skillsSignatureRef.current = skillsSignature;
16541673
...@@ -1700,6 +1719,10 @@ function ComposerPromptEditorInner({...@@ -1700,6 +1719,10 @@ function ComposerPromptEditorInner({
1700 expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),1719 expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),
1701 terminalContextIds: snapshotRef.current.terminalContextIds,1720 terminalContextIds: snapshotRef.current.terminalContextIds,
1702 };1721 };
1722 selectionRangeRef.current = {
1723 start: snapshotRef.current.expandedCursor,
1724 end: snapshotRef.current.expandedCursor,
1725 };
1703 onChangeRef.current(1726 onChangeRef.current(
1704 snapshotRef.current.value,1727 snapshotRef.current.value,
1705 boundedCursor,1728 boundedCursor,
...@@ -1733,6 +1756,7 @@ function ComposerPromptEditorInner({...@@ -1733,6 +1756,7 @@ function ComposerPromptEditorInner({
1733 nextValue,1756 nextValue,
1734 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),1757 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
1735 );1758 );
1759 const selectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection());
1736 const terminalContextIds = collectTerminalContextIds($getRoot());1760 const terminalContextIds = collectTerminalContextIds($getRoot());
1737 snapshot = {1761 snapshot = {
1738 value: nextValue,1762 value: nextValue,
...@@ -1740,6 +1764,10 @@ function ComposerPromptEditorInner({...@@ -1740,6 +1764,10 @@ function ComposerPromptEditorInner({
1740 expandedCursor: nextExpandedCursor,1764 expandedCursor: nextExpandedCursor,
1741 terminalContextIds,1765 terminalContextIds,
1742 };1766 };
1767 selectionRangeRef.current = selectionRange ?? {
1768 start: nextExpandedCursor,
1769 end: nextExpandedCursor,
1770 };
1743 });1771 });
1744 snapshotRef.current = snapshot;1772 snapshotRef.current = snapshot;
1745 return snapshot;1773 return snapshot;
...@@ -1788,18 +1816,28 @@ function ComposerPromptEditorInner({...@@ -1788,18 +1816,28 @@ function ComposerPromptEditorInner({
1788 nextValue,1816 nextValue,
1789 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),1817 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
1790 );1818 );
1819 const nextSelectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection());
1820 const previousSelectionRange = selectionRangeRef.current;
1821 selectionRangeRef.current = nextSelectionRange ?? {
1822 start: nextExpandedCursor,
1823 end: nextExpandedCursor,
1824 };
1791 const terminalContextIds = collectTerminalContextIds($getRoot());1825 const terminalContextIds = collectTerminalContextIds($getRoot());
1792 const previousSnapshot = snapshotRef.current;1826 const previousSnapshot = snapshotRef.current;
1793 if (1827 const snapshotChanged = !(
1794 previousSnapshot.value === nextValue &&1828 previousSnapshot.value === nextValue &&
1795 previousSnapshot.cursor === nextCursor &&1829 previousSnapshot.cursor === nextCursor &&
1796 previousSnapshot.expandedCursor === nextExpandedCursor &&1830 previousSnapshot.expandedCursor === nextExpandedCursor &&
1797 previousSnapshot.terminalContextIds.length === terminalContextIds.length &&1831 previousSnapshot.terminalContextIds.length === terminalContextIds.length &&
1798 previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index])1832 previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index])
1799 ) {1833 );
1834 if (isApplyingControlledUpdateRef.current) {
1800 return;1835 return;
1801 }1836 }
1802 if (isApplyingControlledUpdateRef.current) {1837 if (!snapshotChanged) {
1838 if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) {
1839 onVisibleSelectionChangeRef.current?.();
1840 }
1803 return;1841 return;
1804 }1842 }
1805 snapshotRef.current = {1843 snapshotRef.current = {
...@@ -1824,7 +1862,12 @@ function ComposerPromptEditorInner({...@@ -1824,7 +1862,12 @@ function ComposerPromptEditorInner({
1824 return (1862 return (
1825 <ComposerTerminalContextActionsContext value={terminalContextActions}>1863 <ComposerTerminalContextActionsContext value={terminalContextActions}>
1826 <ComposerCitationCommentContext value={citationCommentActions}>1864 <ComposerCitationCommentContext value={citationCommentActions}>
1827 <div className="relative [font-family:var(--font-composer,var(--font-sans))] [font-size:var(--font-size-prompt,0.875rem)] [@media(max-width:39.999rem)_and_(pointer:coarse)]:[font-size:max(var(--font-size-prompt,1rem),16px)]">1865 <div
1866 className={cn(
1867 "relative [font-family:var(--font-composer,var(--font-sans))] [font-size:var(--font-size-prompt,0.875rem)] [@media(max-width:39.999rem)_and_(pointer:coarse)]:[font-size:max(var(--font-size-prompt,1rem),16px)]",
1868 containerClassName,
1869 )}
1870 >
1828 <PlainTextPlugin1871 <PlainTextPlugin
1829 contentEditable={1872 contentEditable={
1830 <ContentEditable1873 <ContentEditable
...@@ -1881,7 +1924,12 @@ function ComposerPromptEditorInner({...@@ -1881,7 +1924,12 @@ function ComposerPromptEditorInner({
1881 }1924 }
1882 placeholder={1925 placeholder={
1883 terminalContexts.length > 0 ? null : (1926 terminalContexts.length > 0 ? null : (
1884 <div className="pointer-events-none absolute inset-0 leading-relaxed text-placeholder">1927 <div
1928 className={cn(
1929 "pointer-events-none absolute inset-0 leading-relaxed text-placeholder",
1930 placeholderClassName,
1931 )}
1932 >
1885 {placeholder}1933 {placeholder}
1886 </div>1934 </div>
1887 )1935 )
...@@ -1911,9 +1959,12 @@ export function ComposerPromptEditor({...@@ -1911,9 +1959,12 @@ export function ComposerPromptEditor({
1911 skills,1959 skills,
1912 disabled,1960 disabled,
1913 placeholder,1961 placeholder,
1962 containerClassName,
1914 className,1963 className,
1964 placeholderClassName,
1915 onRemoveTerminalContext,1965 onRemoveTerminalContext,
1916 onChange,1966 onChange,
1967 onVisibleSelectionChange,
1917 onCommandKeyDown,1968 onCommandKeyDown,
1918 onPageScrollKeyDown,1969 onPageScrollKeyDown,
1919 onPageScrollKeyUp,1970 onPageScrollKeyUp,
...@@ -1957,8 +2008,10 @@ export function ComposerPromptEditor({...@@ -1957,8 +2008,10 @@ export function ComposerPromptEditor({
1957 skills={skills}2008 skills={skills}
1958 disabled={disabled}2009 disabled={disabled}
1959 placeholder={placeholder}2010 placeholder={placeholder}
2011 {...(containerClassName ? { containerClassName } : {})}
1960 onRemoveTerminalContext={onRemoveTerminalContext}2012 onRemoveTerminalContext={onRemoveTerminalContext}
1961 onChange={onChange}2013 onChange={onChange}
2014 {...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})}
1962 onPaste={onPaste}2015 onPaste={onPaste}
1963 editorRef={editorRef}2016 editorRef={editorRef}
1964 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}2017 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
...@@ -1966,6 +2019,7 @@ export function ComposerPromptEditor({...@@ -1966,6 +2019,7 @@ export function ComposerPromptEditor({
1966 {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})}2019 {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})}
1967 {...(onPageScrollRelease ? { onPageScrollRelease } : {})}2020 {...(onPageScrollRelease ? { onPageScrollRelease } : {})}
1968 {...(className ? { className } : {})}2021 {...(className ? { className } : {})}
2022 {...(placeholderClassName ? { placeholderClassName } : {})}
1969 />2023 />
1970 </LexicalComposer>2024 </LexicalComposer>
1971 );2025 );
apps/web/src/components/chat/AssistantCitationChip.tsx+2
...@@ -23,6 +23,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";...@@ -23,6 +23,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
23import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";23import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
24import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";24import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";
25import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";25import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
26import { composerFloatingLayerProps } from "./composerEventScope";
2627
27const CITATION_ACTION_BUTTON_CLASS_NAME = cn(28const CITATION_ACTION_BUTTON_CLASS_NAME = cn(
28 COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME,29 COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME,
...@@ -135,6 +136,7 @@ export function AssistantCitationChip({...@@ -135,6 +136,7 @@ export function AssistantCitationChip({
135 </PopoverTrigger>136 </PopoverTrigger>
136 {commentEditor.open ? (137 {commentEditor.open ? (
137 <PopoverPopup138 <PopoverPopup
139 {...composerFloatingLayerProps}
138 side={sourceAnchor ? "bottom" : "top"}140 side={sourceAnchor ? "bottom" : "top"}
139 align="end"141 align="end"
140 anchor={popupAnchor}142 anchor={popupAnchor}
apps/web/src/components/chat/ChatComposer.tsx+1200-128
...@@ -23,6 +23,7 @@ import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/...@@ -23,6 +23,7 @@ import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/
23import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";23import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
24import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";24import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
25import {25import {
26 Fragment,
26 memo,27 memo,
27 type ReactNode,28 type ReactNode,
28 useCallback,29 useCallback,
...@@ -51,6 +52,11 @@ import {...@@ -51,6 +52,11 @@ import {
51 dataTransferHasComposerMention,52 dataTransferHasComposerMention,
52 makeComposerMentionDragHandlers,53 makeComposerMentionDragHandlers,
53} from "./composerMentionDrag";54} from "./composerMentionDrag";
55import {
56 composerFloatingLayerProps,
57 isInsideComposerFloatingLayer,
58 isInsideRestingComposerControlScope,
59} from "./composerEventScope";
54import {60import {
55 type ComposerFileAttachment,61 type ComposerFileAttachment,
56 type ComposerImageAttachment,62 type ComposerImageAttachment,
...@@ -137,8 +143,12 @@ import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards...@@ -137,8 +143,12 @@ import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards
137import {143import {
138 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,144 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
139 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,145 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
146 getRestingComposerImagePreviewCounts,
147 resolveRestingComposerControlsLayout,
148 shouldAnimateComposerRestingTransition,
140 shouldUseCompactComposerPrimaryActions,149 shouldUseCompactComposerPrimaryActions,
141 shouldUseCompactComposerFooter,150 shouldUseCompactComposerFooter,
151 shouldUseRestingComposerLayout,
142} from "../composerFooterLayout";152} from "../composerFooterLayout";
143import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";153import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";
144import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";154import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";
...@@ -150,7 +160,12 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions";...@@ -150,7 +160,12 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
150import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";160import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
151import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";161import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
152import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";162import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
153import { ComposerControl, ComposerControlIcon, ComposerSelectControl } from "./ComposerControl";163import {
164 ComposerControl,
165 ComposerControlIcon,
166 ComposerControlSeparator,
167 ComposerSelectControl,
168} from "./ComposerControl";
154import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";169import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
155import {170import {
156 searchSlashCommandItems,171 searchSlashCommandItems,
...@@ -171,13 +186,18 @@ import {...@@ -171,13 +186,18 @@ import {
171} from "./ExpandedImagePreview";186} from "./ExpandedImagePreview";
172import { basenameOfPath } from "../../pierre-icons";187import { basenameOfPath } from "../../pierre-icons";
173import { cn, randomUUID } from "~/lib/utils";188import { cn, randomUUID } from "~/lib/utils";
174import { Separator } from "../ui/separator";
175import {189import {
176 getComposerPromptLengthValidationMessage,190 getComposerPromptLengthValidationMessage,
177 getComposerSubmissionValidationMessage,191 getComposerSubmissionValidationMessage,
178 submitComposerDraft,192 submitComposerDraft,
179} from "./composerSubmission";193} from "./composerSubmission";
180import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";194import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
195import {
196 createComposerScrollGestureState,
197 recordComposerScrollGestureEvent,
198 resetComposerScrollGesture,
199 suppressActiveComposerScrollGesture,
200} from "./composerScrollGesture";
181import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";201import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
182202
183function ComposerVideoThumbnail({ file }: { file: File }) {203function ComposerVideoThumbnail({ file }: { file: File }) {
...@@ -209,6 +229,435 @@ type ComposerCommandMenuPosition = {...@@ -209,6 +229,435 @@ type ComposerCommandMenuPosition = {
209 width: number;229 width: number;
210};230};
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
212function composerCommandMenuPositionsEqual(661function composerCommandMenuPositionsEqual(
213 a: ComposerCommandMenuPosition,662 a: ComposerCommandMenuPosition,
214 b: ComposerCommandMenuPosition,663 b: ComposerCommandMenuPosition,
...@@ -375,15 +824,6 @@ const runtimeModeConfig: Record<...@@ -375,15 +824,6 @@ const runtimeModeConfig: Record<
375};824};
376825
377const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];826const 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
387const extendReplacementRangeForTrailingSpace = (827const extendReplacementRangeForTrailingSpace = (
388 text: string,828 text: string,
389 rangeEnd: number,829 rangeEnd: number,
...@@ -412,17 +852,122 @@ const terminalContextIdListsEqual = (...@@ -412,17 +852,122 @@ const terminalContextIdListsEqual = (
412): boolean =>852): boolean =>
413 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);853 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
414854
415function isInsideComposerFloatingLayer(element: Element): boolean {855function elementOuterWidth(element: HTMLElement): number {
416 return element.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null;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 };
417}960}
418961
419const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {962const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
420 showInteractionModeToggle: boolean;963 showInteractionModeToggle: boolean;
421 interactionMode: ProviderInteractionMode;964 interactionMode: ProviderInteractionMode;
422 runtimeMode: RuntimeMode;965 runtimeMode: RuntimeMode;
966 size?: "sm" | "xs";
423 onToggleInteractionMode: () => void;967 onToggleInteractionMode: () => void;
424 onRuntimeModeChange: (mode: RuntimeMode) => void;968 onRuntimeModeChange: (mode: RuntimeMode) => void;
425}) {969}) {
970 const size = props.size ?? "sm";
426 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];971 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];
427 const RuntimeModeIcon = runtimeModeOption.icon;972 const RuntimeModeIcon = runtimeModeOption.icon;
428 const interactionModeTooltip =973 const interactionModeTooltip =
...@@ -432,16 +977,19 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -432,16 +977,19 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
432977
433 const interactionModeToggle = props.showInteractionModeToggle ? (978 const interactionModeToggle = props.showInteractionModeToggle ? (
434 <>979 <>
435 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />980 <ComposerControlSeparator size={size} />
436 <Tooltip>981 <Tooltip>
437 <TooltipTrigger982 <TooltipTrigger
438 render={983 render={
439 <ComposerControl984 <ComposerControl
985 size={size}
440 className={cn(986 className={cn(
441 "shrink-0 whitespace-nowrap",987 "shrink-0 whitespace-nowrap",
442 props.interactionMode === "plan"988 props.interactionMode === "plan"
443 ? "bg-accent text-accent-foreground hover:bg-accent/80"989 ? "bg-accent text-accent-foreground hover:bg-accent/80"
444 : "text-secondary-label hover:text-foreground",990 : size === "xs"
991 ? undefined
992 : "text-secondary-label hover:text-foreground",
445 )}993 )}
446 type="button"994 type="button"
447 onClick={props.onToggleInteractionMode}995 onClick={props.onToggleInteractionMode}
...@@ -450,9 +998,17 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -450,9 +998,17 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
450 }998 }
451 >999 >
452 {props.interactionMode === "plan" ? (1000 {props.interactionMode === "plan" ? (
453 <ComposerControlIcon icon={PencilRulerIcon} className="text-current opacity-100" />1001 <ComposerControlIcon
1002 icon={PencilRulerIcon}
1003 size={size}
1004 className="text-current opacity-100"
1005 />
454 ) : (1006 ) : (
455 <ComposerControlIcon icon={BotIcon} opticalSize="large" />1007 <ComposerControlIcon
1008 icon={BotIcon}
1009 size={size}
1010 opticalSize={size === "xs" ? "default" : "large"}
1011 />
456 )}1012 )}
457 <span className="sr-only sm:not-sr-only">1013 <span className="sr-only sm:not-sr-only">
458 {props.interactionMode === "plan" ? "Plan" : "Build"}1014 {props.interactionMode === "plan" ? "Plan" : "Build"}
...@@ -465,7 +1021,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -465,7 +1021,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
4651021
466 return (1022 return (
467 <>1023 <>
468 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />1024 <ComposerControlSeparator size={size} />
4691025
470 <Tooltip>1026 <Tooltip>
471 <Select1027 <Select
...@@ -473,12 +1029,18 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -473,12 +1029,18 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
473 onValueChange={(value) => props.onRuntimeModeChange(value!)}1029 onValueChange={(value) => props.onRuntimeModeChange(value!)}
474 >1030 >
475 <TooltipTrigger1031 <TooltipTrigger
476 render={<ComposerSelectControl className="font-medium" aria-label="Runtime mode" />}1032 render={
1033 <ComposerSelectControl
1034 size={size}
1035 className={size === "xs" ? undefined : "font-medium"}
1036 aria-label="Runtime mode"
1037 />
1038 }
477 >1039 >
478 <ComposerControlIcon icon={RuntimeModeIcon} />1040 <ComposerControlIcon icon={RuntimeModeIcon} size={size} />
479 <SelectValue>{runtimeModeOption.label}</SelectValue>1041 <SelectValue>{runtimeModeOption.label}</SelectValue>
480 </TooltipTrigger>1042 </TooltipTrigger>
481 <SelectPopup alignItemWithTrigger={false}>1043 <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}>
482 {runtimeModeOptions.map((mode) => {1044 {runtimeModeOptions.map((mode) => {
483 const option = runtimeModeConfig[mode];1045 const option = runtimeModeConfig[mode];
484 const OptionIcon = option.icon;1046 const OptionIcon = option.icon;
...@@ -530,6 +1092,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -530,6 +1092,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
530 hasSendableContent: boolean;1092 hasSendableContent: boolean;
531 preserveComposerFocusOnPointerDown?: boolean;1093 preserveComposerFocusOnPointerDown?: boolean;
532 showSendWhileRunning?: boolean;1094 showSendWhileRunning?: boolean;
1095 showSecondaryStatus: boolean;
533 onPreviousPendingQuestion: () => void;1096 onPreviousPendingQuestion: () => void;
534 onInterrupt: () => void;1097 onInterrupt: () => void;
535 onImplementPlanInNewThread: () => void;1098 onImplementPlanInNewThread: () => void;
...@@ -539,7 +1102,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -539,7 +1102,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
539}) {1102}) {
540 return (1103 return (
541 <>1104 <>
542 {props.activeContextWindow ? (1105 {props.showSecondaryStatus && props.activeContextWindow ? (
543 <ContextWindowMeter1106 <ContextWindowMeter
544 usage={props.activeContextWindow}1107 usage={props.activeContextWindow}
545 modelDisplayName={props.activeThreadModelDisplayName}1108 modelDisplayName={props.activeThreadModelDisplayName}
...@@ -577,6 +1140,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -577,6 +1140,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
577export interface ChatComposerHandle {1140export interface ChatComposerHandle {
578 focusAtEnd: () => void;1141 focusAtEnd: () => void;
579 focusAt: (cursor: number) => void;1142 focusAt: (cursor: number) => void;
1143 /** Undo only a scroll-triggered collapse when the timeline returns to its live edge. */
1144 restoreAfterTimelineReachedEnd: () => void;
580 addDroppedFiles: (files: File[]) => void;1145 addDroppedFiles: (files: File[]) => void;
581 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;1146 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
582 citeAssistantText: (1147 citeAssistantText: (
...@@ -702,6 +1267,12 @@ export interface ChatComposerProps {...@@ -702,6 +1267,12 @@ export interface ChatComposerProps {
702 keybindings: ResolvedKeybindingsConfig;1267 keybindings: ResolvedKeybindingsConfig;
703 terminalOpen: boolean;1268 terminalOpen: boolean;
704 gitCwd: string | null;1269 gitCwd: string | null;
1270 restingControlsHost: HTMLDivElement | null;
1271 restingControlsHaveLeadingContext: boolean;
1272 onRestingControlsVisibilityChange: (visible: boolean) => void;
1273 getTimelineScrollableNode: () => HTMLElement | null;
1274 isTimelineAtLogicalEnd: () => boolean;
1275 onComposerOverlayHeightChange: (height: number) => void;
7051276
706 // Refs the parent needs kept in sync1277 // Refs the parent needs kept in sync
707 promptRef: React.RefObject<string>;1278 promptRef: React.RefObject<string>;
...@@ -798,6 +1369,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -798,6 +1369,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
798 keybindings,1369 keybindings,
799 terminalOpen,1370 terminalOpen,
800 gitCwd,1371 gitCwd,
1372 restingControlsHost,
1373 restingControlsHaveLeadingContext,
1374 onRestingControlsVisibilityChange,
1375 getTimelineScrollableNode,
1376 isTimelineAtLogicalEnd,
1377 onComposerOverlayHeightChange,
801 promptRef,1378 promptRef,
802 composerRef,1379 composerRef,
803 composerImagesRef,1380 composerImagesRef,
...@@ -849,6 +1426,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -849,6 +1426,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
849 const composerElementContexts = composerDraft.elementContexts;1426 const composerElementContexts = composerDraft.elementContexts;
850 const composerPreviewAnnotations = composerDraft.previewAnnotations;1427 const composerPreviewAnnotations = composerDraft.previewAnnotations;
851 const composerReviewComments = composerDraft.reviewComments;1428 const composerReviewComments = composerDraft.reviewComments;
1429 const standaloneComposerImages = useMemo(() => {
1430 const previewAnnotationIds = new Set(
1431 composerPreviewAnnotations.map((annotation) => annotation.id),
1432 );
1433 return composerImages.filter((image) => !previewAnnotationIds.has(image.id));
1434 }, [composerImages, composerPreviewAnnotations]);
852 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;1435 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
853 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);1436 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
854 const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;1437 const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;
...@@ -1288,6 +1871,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1288,6 +1871,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1288 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);1871 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
1289 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);1872 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
1290 const [isComposerFocused, setIsComposerFocused] = useState(false);1873 const [isComposerFocused, setIsComposerFocused] = useState(false);
1874 const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false);
1291 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);1875 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);
1292 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(1876 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(
1293 null,1877 null,
...@@ -1322,6 +1906,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1322,6 +1906,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1322 const mobileComposerExpandFrameRef = useRef<number | null>(null);1906 const mobileComposerExpandFrameRef = useRef<number | null>(null);
1323 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);1907 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
1324 const mobileComposerExpandInFlightRef = useRef(false);1908 const mobileComposerExpandInFlightRef = useRef(false);
1909 const desktopOutsidePointerInFlightRef = useRef(false);
1910 const desktopOutsidePointerReleaseTimeoutRef = useRef<number | null>(null);
1911 const composerScrollCollapseTimeoutRef = useRef<number | null>(null);
1912 const composerScrollCollapseEligibleRef = useRef(false);
1913 const windowRefocusInFlightRef = useRef(false);
1914 const composerScrollGestureRef = useRef(createComposerScrollGestureState());
1325 const stashPulseKeyRef = useRef(0);1915 const stashPulseKeyRef = useRef(0);
1326 const stashPulseTimeoutRef = useRef<number | null>(null);1916 const stashPulseTimeoutRef = useRef<number | null>(null);
1327 /**1917 /**
...@@ -1509,7 +2099,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1509,7 +2099,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1509 (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);2099 (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);
1510 const showCollapsedMobilePromptRow =2100 const showCollapsedMobilePromptRow =
1511 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;2101 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
15122102 const showComposerAttachAction = fileStagingLimit !== null && pendingUserInputs.length === 0;
1513 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;2103 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
1514 const composerFooterActionLayoutKey = useMemo(() => {2104 const composerFooterActionLayoutKey = useMemo(() => {
1515 if (activePendingProgress) {2105 if (activePendingProgress) {
...@@ -1576,7 +2166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1576,7 +2166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1576 onPromptChange: setPromptFromTraits,2166 onPromptChange: setPromptFromTraits,
1577 planModeEnabled: settings.planModeEnabled,2167 planModeEnabled: settings.planModeEnabled,
1578 });2168 });
1579 const providerTraitsPicker = renderProviderTraitsPicker({2169 const providerTraitsPickerInput = {
1580 provider: selectedProvider,2170 provider: selectedProvider,
1581 instanceId: selectedInstanceId,2171 instanceId: selectedInstanceId,
1582 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),2172 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
...@@ -1587,7 +2177,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1587,7 +2177,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1587 prompt,2177 prompt,
1588 onPromptChange: setPromptFromTraits,2178 onPromptChange: setPromptFromTraits,
1589 planModeEnabled: settings.planModeEnabled,2179 planModeEnabled: settings.planModeEnabled,
2180 isComposerOwned: true,
2181 } satisfies Parameters<typeof renderProviderTraitsPicker>[0];
2182 const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput);
2183 const restingProviderTraitsPicker = renderProviderTraitsPicker({
2184 ...providerTraitsPickerInput,
2185 size: "xs",
1590 });2186 });
2187 const {
2188 controlsRef: restingComposerControlsRef,
2189 hiddenBlockCount: restingControlsHiddenBlockCount,
2190 controlsVisible: restingControlsVisible,
2191 } = useRestingComposerControlsLayout(restingControlsHost);
1591 const pendingPrimaryAction = useMemo(2192 const pendingPrimaryAction = useMemo(
1592 () =>2193 () =>
1593 activePendingProgress2194 activePendingProgress
...@@ -1820,6 +2421,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1820,6 +2421,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1820 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));2421 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
1821 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));2422 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
1822 setIsDragOverComposer(false);2423 setIsDragOverComposer(false);
2424 setIsComposerScrollCollapsed(false);
1823 }, [draftId, activeThreadId, promptRef]);2425 }, [draftId, activeThreadId, promptRef]);
18242426
1825 // ------------------------------------------------------------------2427 // ------------------------------------------------------------------
...@@ -1963,6 +2565,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1963,6 +2565,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1963 // ------------------------------------------------------------------2565 // ------------------------------------------------------------------
1964 // Callbacks: prompt change2566 // Callbacks: prompt change
1965 // ------------------------------------------------------------------2567 // ------------------------------------------------------------------
2568 const expandComposerForEditorChange = useCallback(() => {
2569 // Editor changes win over the momentum tail of the active scroll gesture.
2570 suppressActiveComposerScrollGesture(
2571 composerScrollGestureRef.current,
2572 window.performance.now(),
2573 COMPOSER_SCROLL_GESTURE_RESET_MS,
2574 );
2575 setIsComposerScrollCollapsed(false);
2576 }, []);
2577
1966 const onPromptChange = useCallback(2578 const onPromptChange = useCallback(
1967 (2579 (
1968 nextPrompt: string,2580 nextPrompt: string,
...@@ -1971,6 +2583,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1971,6 +2583,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1971 cursorAdjacentToMention: boolean,2583 cursorAdjacentToMention: boolean,
1972 terminalContextIds: string[],2584 terminalContextIds: string[],
1973 ) => {2585 ) => {
2586 expandComposerForEditorChange();
1974 if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {2587 if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {
1975 setComposerCursor(nextCursor);2588 setComposerCursor(nextCursor);
1976 setComposerTrigger(2589 setComposerTrigger(
...@@ -2000,6 +2613,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2000,6 +2613,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2000 },2613 },
2001 [2614 [
2002 activePendingProgress?.activeQuestion,2615 activePendingProgress?.activeQuestion,
2616 expandComposerForEditorChange,
2003 pendingUserInputs.length,2617 pendingUserInputs.length,
2004 onChangeActivePendingUserInputCustomAnswer,2618 onChangeActivePendingUserInputCustomAnswer,
2005 promptRef,2619 promptRef,
...@@ -2061,6 +2675,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2061,6 +2675,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2061 setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor));2675 setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor));
2062 if (options?.focusEditorAfterReplace !== false) {2676 if (options?.focusEditorAfterReplace !== false) {
2063 window.requestAnimationFrame(() => {2677 window.requestAnimationFrame(() => {
2678 // Type-to-focus routes only the first key through here; once the
2679 // controlled update focuses the editor, later keys land natively.
2680 // Skip the deferred caret placement when the draft has moved on,
2681 // or it drags the caret back behind what was typed since.
2682 if (promptRef.current !== next.text) return;
2064 composerEditorRef.current?.focusAt(nextCursor);2683 composerEditorRef.current?.focusAt(nextCursor);
2065 });2684 });
2066 }2685 }
...@@ -2644,7 +3263,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2644,7 +3263,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2644 // unique image into the overflow list for nothing.3263 // unique image into the overflow list for nothing.
2645 const existingDedupKeys = new Set(3264 const existingDedupKeys = new Set(
2646 composerImagesRef.current.map(3265 composerImagesRef.current.map(
2647 (image) => `${image.mimeType}${image.sizeBytes}${image.name}`,3266 (image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`,
2648 ),3267 ),
2649 );3268 );
2650 const capacity = Math.max(3269 const capacity = Math.max(
...@@ -2658,7 +3277,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2658,7 +3277,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2658 (attachment) =>3277 (attachment) =>
2659 !existingIds.has(attachment.id) &&3278 !existingIds.has(attachment.id) &&
2660 !existingDedupKeys.has(3279 !existingDedupKeys.has(
2661 `${attachment.mimeType}${attachment.sizeBytes}${attachment.name}`,3280 `${attachment.mimeType}\0${attachment.sizeBytes}\0${attachment.name}`,
2662 ),3281 ),
2663 );3282 );
2664 // Anything past the attachment limit cannot be restored. The entry is3283 // Anything past the attachment limit cannot be restored. The entry is
...@@ -2802,10 +3421,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2802,10 +3421,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2802 // the composer has been cleared the user can type something genuinely3421 // the composer has been cleared the user can type something genuinely
2803 // new (or switch threads) while encoding continues, and that deserves its3422 // new (or switch threads) while encoding continues, and that deserves its
2804 // own entry.3423 // own entry.
2805 const snapshotKey = `${String(composerDraftTarget)}${prompt}${images3424 const attachmentKey = images
2806 .map((image) => `image:${image.id}`)3425 .map((image) => `image:${image.id}`)
2807 .concat(files.map((file) => `file:${file.id}`))3426 .concat(files.map((file) => `file:${file.id}`))
2808 .join(",")}`;3427 .join(",");
3428 const snapshotKey = [String(composerDraftTarget), prompt, attachmentKey].join("\n");
2809 if (stashInFlightRef.current.has(snapshotKey)) return;3429 if (stashInFlightRef.current.has(snapshotKey)) return;
2810 stashInFlightRef.current.add(snapshotKey);3430 stashInFlightRef.current.add(snapshotKey);
28113431
...@@ -2986,6 +3606,365 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2986,6 +3606,365 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2986 steps={activeTaskSteps}3606 steps={activeTaskSteps}
2987 />3607 />
2988 ) : null;3608 ) : null;
3609 const hasImageAttachmentAttention = standaloneComposerImages.some((image) => {
3610 const upload = uploadsByImageId[image.id];
3611 const failedInCurrentEnvironment =
3612 supportsAttachmentUploads &&
3613 upload?.status === "failed" &&
3614 upload.environmentId === environmentId;
3615 // A failed upload remains actionable in the expanded attachment tray, but
3616 // its collapsed thumbnail is enough to signal that the draft has images.
3617 return nonPersistedComposerImageIdSet.has(image.id) && !failedInCurrentEnvironment;
3618 });
3619 // Banners and the tasks badge dock above the surface rather than inside
3620 // it, so they do not hold the composer open; only surface-internal chrome
3621 // does.
3622 const composerHasExpandedChrome =
3623 showComposerTopDrawer ||
3624 isTasksDrawerOpen ||
3625 composerMenuOpen ||
3626 isStashMenuOpen ||
3627 isDragOverComposer ||
3628 isPreparingWorktree ||
3629 noProviderAvailable ||
3630 projectSelectionRequired ||
3631 environmentUnavailable !== null ||
3632 composerSubmissionError !== null ||
3633 providerInputSubmissionError !== null ||
3634 hasImageAttachmentAttention;
3635 const isComposerResting = shouldUseRestingComposerLayout({
3636 isExistingThread: routeKind === "server" && activeThreadId !== null,
3637 isMobileViewport,
3638 isFocused: isComposerFocused && !isComposerScrollCollapsed,
3639 hasExpandedChrome: composerHasExpandedChrome,
3640 });
3641 // The relocated controls live in the context strip whenever the composer is
3642 // collapsed for any reason, the desktop resting layout or the phone
3643 // collapse. Both leave the footer unrendered, so the strip is the only place
3644 // to see or change the model without expanding the composer.
3645 const composerControlsInStrip = isComposerResting || isComposerCollapsedMobile;
3646 const composerControlsVisibleInStrip = composerControlsInStrip && restingControlsVisible;
3647 useLayoutEffect(() => {
3648 onRestingControlsVisibilityChange(composerControlsVisibleInStrip);
3649 }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]);
3650 const restingImagePreviewCounts = getRestingComposerImagePreviewCounts(
3651 standaloneComposerImages.length,
3652 );
3653 const restingComposerImages = standaloneComposerImages.slice(
3654 0,
3655 restingImagePreviewCounts.visibleCount,
3656 );
3657 const collapsedComposerImagePreviews =
3658 restingComposerImages.length > 0 ? (
3659 <div
3660 data-chat-composer-resting-images="true"
3661 className="flex shrink-0 items-center gap-1 ps-1"
3662 >
3663 {restingComposerImages.map((image) => (
3664 <button
3665 key={image.id}
3666 type="button"
3667 className="relative size-7 shrink-0 cursor-zoom-in overflow-hidden rounded-md border border-border/70 bg-muted/60"
3668 aria-label={`Preview ${image.name}`}
3669 onPointerDown={(event) => event.preventDefault()}
3670 onClick={() => {
3671 const preview = buildExpandedImagePreview(composerImages, image.id);
3672 if (preview) onExpandImage(preview);
3673 }}
3674 >
3675 {image.previewUrl ? (
3676 <img src={image.previewUrl} alt="" className="size-full object-cover" />
3677 ) : (
3678 <FileIcon className="m-auto size-3.5 text-secondary-label" />
3679 )}
3680 </button>
3681 ))}
3682 {restingImagePreviewCounts.overflowCount > 0 ? (
3683 <button
3684 type="button"
3685 className="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md border border-border/70 bg-muted/60 font-medium text-secondary-label text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
3686 aria-label={`Show ${String(restingImagePreviewCounts.overflowCount)} more image attachments`}
3687 onPointerDown={(event) => event.preventDefault()}
3688 onClick={() => {
3689 if (isComposerCollapsedMobile) {
3690 expandMobileComposer();
3691 return;
3692 }
3693 setIsComposerScrollCollapsed(false);
3694 setIsComposerFocused(true);
3695 scheduleComposerFocus();
3696 }}
3697 >
3698 +{restingImagePreviewCounts.overflowCount}
3699 </button>
3700 ) : null}
3701 </div>
3702 ) : null;
3703 const composerMainSurfaceRef = useComposerRestingTransition(
3704 composerControlsInStrip,
3705 restingComposerControlsRef,
3706 onComposerOverlayHeightChange,
3707 );
3708 const canTrackComposerScrollGesture =
3709 routeKind === "server" && activeThreadId !== null && !isMobileViewport;
3710 const canScrollCollapseComposer =
3711 canTrackComposerScrollGesture && !composerHasExpandedChrome && !showInlineTasksBadge;
3712 composerScrollCollapseEligibleRef.current = canScrollCollapseComposer;
3713
3714 useEffect(() => {
3715 if (!canScrollCollapseComposer) {
3716 setIsComposerScrollCollapsed(false);
3717 }
3718 }, [canScrollCollapseComposer]);
3719
3720 // Returning to the window re-fires focus on the element that already held
3721 // it. That focus arrives after the window's own event, so a window focus
3722 // marks the frame in which it should be ignored by the form's capture.
3723 useEffect(() => {
3724 if (!isComposerScrollCollapsed) return;
3725 let frame: number | null = null;
3726 const onWindowFocus = () => {
3727 windowRefocusInFlightRef.current = true;
3728 if (frame !== null) window.cancelAnimationFrame(frame);
3729 frame = window.requestAnimationFrame(() => {
3730 frame = null;
3731 windowRefocusInFlightRef.current = false;
3732 });
3733 };
3734 window.addEventListener("focus", onWindowFocus);
3735 return () => {
3736 window.removeEventListener("focus", onWindowFocus);
3737 if (frame !== null) window.cancelAnimationFrame(frame);
3738 windowRefocusInFlightRef.current = false;
3739 };
3740 }, [isComposerScrollCollapsed]);
3741
3742 useEffect(() => {
3743 if (!canTrackComposerScrollGesture) return;
3744
3745 const finishScrollGesture = () => {
3746 if (composerScrollCollapseTimeoutRef.current !== null) {
3747 window.clearTimeout(composerScrollCollapseTimeoutRef.current);
3748 }
3749 composerScrollCollapseTimeoutRef.current = null;
3750 resetComposerScrollGesture(composerScrollGestureRef.current);
3751 };
3752 const handleTimelineWheel = (event: WheelEvent) => {
3753 const activeElement = document.activeElement;
3754 const isPromptEditorFocused =
3755 activeElement instanceof HTMLElement &&
3756 activeElement.isContentEditable &&
3757 composerFormRef.current?.contains(activeElement) === true;
3758 if (event.ctrlKey || !(event.target instanceof Element)) {
3759 return;
3760 }
3761
3762 const scrollNode = getTimelineScrollableNode();
3763 if (!scrollNode) return;
3764 const targetsTimeline = scrollNode.contains(event.target);
3765 if (!targetsTimeline && !composerScrollGestureRef.current.collapseSuppressed) return;
3766
3767 if (composerScrollCollapseTimeoutRef.current !== null) {
3768 window.clearTimeout(composerScrollCollapseTimeoutRef.current);
3769 }
3770 composerScrollCollapseTimeoutRef.current = window.setTimeout(
3771 finishScrollGesture,
3772 COMPOSER_SCROLL_GESTURE_RESET_MS,
3773 );
3774
3775 const canScrollInGestureDirection =
3776 targetsTimeline &&
3777 (event.deltaY < 0
3778 ? scrollNode.scrollTop > 0
3779 : scrollNode.scrollTop < scrollNode.scrollHeight - scrollNode.clientHeight);
3780 const deltaPx =
3781 Math.abs(event.deltaY) *
3782 (event.deltaMode === WheelEvent.DOM_DELTA_LINE
3783 ? 16
3784 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
3785 ? scrollNode.clientHeight
3786 : 1);
3787 const shouldCollapse = recordComposerScrollGestureEvent(composerScrollGestureRef.current, {
3788 now: window.performance.now(),
3789 deltaPx,
3790 collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX,
3791 collapseEligible:
3792 targetsTimeline && composerScrollCollapseEligibleRef.current && isPromptEditorFocused,
3793 canScrollInGestureDirection,
3794 scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(),
3795 });
3796 if (!shouldCollapse) {
3797 return;
3798 }
3799
3800 setIsComposerScrollCollapsed(true);
3801 };
3802
3803 document.addEventListener("wheel", handleTimelineWheel, { capture: true, passive: true });
3804 return () => {
3805 document.removeEventListener("wheel", handleTimelineWheel, true);
3806 finishScrollGesture();
3807 };
3808 }, [
3809 activeThreadId,
3810 canTrackComposerScrollGesture,
3811 getTimelineScrollableNode,
3812 isTimelineAtLogicalEnd,
3813 ]);
3814
3815 const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0;
3816 const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact;
3817 const restingBlockDefs = [
3818 ...(providerTraitsPicker
3819 ? [
3820 {
3821 id: "traits",
3822 content: (
3823 <>
3824 <ComposerControlSeparator size={composerControlsInStrip ? "xs" : "sm"} />
3825 {composerControlsInStrip ? restingProviderTraitsPicker : providerTraitsPicker}
3826 </>
3827 ),
3828 },
3829 ]
3830 : []),
3831 {
3832 id: "mode",
3833 content: (
3834 <ComposerFooterModeControls
3835 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
3836 interactionMode={interactionMode}
3837 runtimeMode={runtimeMode}
3838 size={composerControlsInStrip ? "xs" : "sm"}
3839 onToggleInteractionMode={toggleInteractionMode}
3840 onRuntimeModeChange={handleRuntimeModeChange}
3841 />
3842 ),
3843 },
3844 ];
3845 const hiddenRestingBlockIds = restingBlockDefs
3846 .slice(restingBlockDefs.length - restingHiddenBlockCount)
3847 .map((def) => def.id);
3848 const composerControls = noProviderAvailable ? (
3849 <Button
3850 type="button"
3851 size="sm"
3852 variant="ghost"
3853 disabled
3854 data-chat-provider-unavailable="true"
3855 className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3"
3856 >
3857 <CircleAlertIcon className="size-4" />
3858 No provider available
3859 </Button>
3860 ) : (
3861 <>
3862 {composerControlsInStrip && restingControlsHaveLeadingContext ? (
3863 <ComposerControlSeparator
3864 size="xs"
3865 className="@max-[400px]/composer-surface:hidden"
3866 data-resting-controls-separator="true"
3867 />
3868 ) : null}
3869 <ProviderModelPicker
3870 isComposerOwned
3871 compact={composerControlsCompact}
3872 activeInstanceId={selectedInstanceId}
3873 model={selectedModelForPickerWithCustomFallback}
3874 lockedProvider={lockedProvider}
3875 lockedContinuationGroupKey={lockedContinuationGroupKey}
3876 instanceEntries={providerInstanceEntries}
3877 keybindings={keybindings}
3878 modelOptionsByInstance={modelOptionsByInstance}
3879 size={composerControlsInStrip ? "xs" : "sm"}
3880 triggerClassName={
3881 composerControlsInStrip
3882 ? "min-w-13 shrink text-xs! @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:w-0 @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:flex-none"
3883 : "-ms-2.5"
3884 }
3885 terminalOpen={terminalOpen}
3886 open={isComposerModelPickerOpen}
3887 instanceIndicatorBackground={
3888 composerControlsInStrip
3889 ? "color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent)"
3890 : "var(--contrast-input)"
3891 }
3892 {...(composerProviderState.modelPickerIconClassName || composerControlsInStrip
3893 ? {
3894 activeProviderIconClassName: cn(
3895 composerProviderState.modelPickerIconClassName,
3896 composerControlsInStrip &&
3897 "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70!",
3898 ),
3899 }
3900 : {})}
3901 onOpenChange={setIsComposerModelPickerOpen}
3902 getModelDisabledReason={getModelDisabledReason}
3903 onInstanceModelChange={onProviderModelSelect}
3904 />
3905
3906 {composerControlsCompact ? (
3907 <CompactComposerControlsMenu
3908 interactionMode={interactionMode}
3909 runtimeMode={runtimeMode}
3910 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
3911 traitsMenuContent={providerTraitsMenuContent}
3912 onToggleInteractionMode={toggleInteractionMode}
3913 onRuntimeModeChange={handleRuntimeModeChange}
3914 />
3915 ) : (
3916 <>
3917 {restingBlockDefs.map((def, index) => {
3918 if (!composerControlsInStrip) {
3919 return <Fragment key={def.id}>{def.content}</Fragment>;
3920 }
3921 const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount;
3922 return (
3923 <div
3924 key={`${def.id}:${hidden ? "hidden" : "visible"}`}
3925 data-resting-block={def.id}
3926 aria-hidden={hidden || undefined}
3927 inert={hidden || undefined}
3928 className={cn(
3929 "flex w-max min-w-max shrink-0 items-center gap-1",
3930 hidden && "pointer-events-none invisible absolute",
3931 )}
3932 >
3933 {def.content}
3934 </div>
3935 );
3936 })}
3937 {composerControlsInStrip ? (
3938 <div
3939 key={hiddenRestingBlockIds.length > 0 ? "overflow:visible" : "overflow:hidden"}
3940 data-resting-controls-overflow
3941 aria-hidden={hiddenRestingBlockIds.length === 0 || undefined}
3942 inert={hiddenRestingBlockIds.length === 0 || undefined}
3943 className={cn(
3944 "min-w-0 shrink-0",
3945 hiddenRestingBlockIds.length === 0 && "pointer-events-none invisible absolute",
3946 )}
3947 >
3948 <CompactComposerControlsMenu
3949 interactionMode={interactionMode}
3950 runtimeMode={runtimeMode}
3951 size="xs"
3952 showInteractionModeToggle={
3953 composerProviderControls.showInteractionModeToggle &&
3954 hiddenRestingBlockIds.includes("mode")
3955 }
3956 traitsMenuContent={
3957 hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined
3958 }
3959 onToggleInteractionMode={toggleInteractionMode}
3960 onRuntimeModeChange={handleRuntimeModeChange}
3961 />
3962 </div>
3963 ) : null}
3964 </>
3965 )}
3966 </>
3967 );
2989 const showTasksTab =3968 const showTasksTab =
2990 !hasBannerItems &&3969 !hasBannerItems &&
2991 !showComposerTopDrawer &&3970 !showComposerTopDrawer &&
...@@ -3318,8 +4297,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3318,8 +4297,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3318 );4297 );
33194298
3320 const insertComposerTextAtEnd = useCallback<ChatComposerHandle["insertTextAtEnd"]>(4299 const insertComposerTextAtEnd = useCallback<ChatComposerHandle["insertTextAtEnd"]>(
3321 (text, options) => insertComposerText(text, "end", options),4300 (text, options) => {
3322 [insertComposerText],4301 const inserted = insertComposerText(text, "end", options);
4302 if (inserted && isComposerCollapsedMobile) {
4303 // The expanded editor is hidden at phone widths, so its scheduled
4304 // focus cannot expand the composer by itself. Reveal it before the
4305 // focus frame runs to preserve type-to-focus and external inserts.
4306 expandMobileComposer();
4307 }
4308 return inserted;
4309 },
4310 [expandMobileComposer, insertComposerText, isComposerCollapsedMobile],
3323 );4311 );
33244312
3325 // File-tree drags land as mentions. Handled in the capture phase so the4313 // File-tree drags land as mentions. Handled in the capture phase so the
...@@ -3364,10 +4352,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3364,10 +4352,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3364 void onImplementPlanInNewThread();4352 void onImplementPlanInNewThread();
3365 }, [onImplementPlanInNewThread]);4353 }, [onImplementPlanInNewThread]);
3366 const scheduleComposerCollapseCheck = useCallback(() => {4354 const scheduleComposerCollapseCheck = useCallback(() => {
3367 if (!isMobileViewport) {4355 if (isMobileViewport && mobileComposerExpandInFlightRef.current) {
3368 return;
3369 }
3370 if (mobileComposerExpandInFlightRef.current) {
3371 return;4356 return;
3372 }4357 }
3373 if (composerBlurFrameRef.current !== null) {4358 if (composerBlurFrameRef.current !== null) {
...@@ -3375,7 +4360,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3375,7 +4360,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3375 }4360 }
3376 composerBlurFrameRef.current = window.requestAnimationFrame(() => {4361 composerBlurFrameRef.current = window.requestAnimationFrame(() => {
3377 composerBlurFrameRef.current = null;4362 composerBlurFrameRef.current = null;
3378 if (mobileComposerExpandInFlightRef.current) {4363 if (isMobileViewport && mobileComposerExpandInFlightRef.current) {
4364 return;
4365 }
4366 if (!isMobileViewport && desktopOutsidePointerInFlightRef.current) {
3379 return;4367 return;
3380 }4368 }
3381 const composerSurface = composerSurfaceRef.current;4369 const composerSurface = composerSurfaceRef.current;
...@@ -3395,6 +4383,73 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3395,6 +4383,73 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3395 });4383 });
3396 }, [isMobileViewport]);4384 }, [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
3398 useEffect(() => {4453 useEffect(() => {
3399 return () => {4454 return () => {
3400 if (composerBlurFrameRef.current !== null) {4455 if (composerBlurFrameRef.current !== null) {
...@@ -3421,6 +4476,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3421,6 +4476,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3421 focusAt: (cursor: number) => {4476 focusAt: (cursor: number) => {
3422 composerEditorRef.current?.focusAt(cursor);4477 composerEditorRef.current?.focusAt(cursor);
3423 },4478 },
4479 restoreAfterTimelineReachedEnd: () => {
4480 setIsComposerScrollCollapsed(false);
4481 },
3424 addDroppedFiles: (files: File[]) => {4482 addDroppedFiles: (files: File[]) => {
3425 void addComposerAttachments(files);4483 void addComposerAttachments(files);
3426 focusComposer();4484 focusComposer();
...@@ -3564,8 +4622,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3564,8 +4622,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3564 <form4622 <form
3565 ref={composerFormRef}4623 ref={composerFormRef}
3566 onSubmit={submitComposer}4624 onSubmit={submitComposer}
4625 onPointerDownCapture={(event) => {
4626 const target = event.target;
4627 if (isInsideRestingComposerControlScope(target)) return;
4628 if (!(target instanceof Element)) return;
4629 const isInteractive = Boolean(
4630 target.closest('button, a, input, select, [role="button"], [role="menuitem"]'),
4631 );
4632 if (isInteractive) return;
4633
4634 setIsComposerScrollCollapsed(false);
4635 if (isComposerResting && !target.closest('[data-testid="composer-editor"]')) {
4636 // Clicking resting-surface padding would otherwise blur the still
4637 // focused editor after pointerdown: expansion starts, the blur check
4638 // runs, and it immediately collapses again. Treat that padding like
4639 // the editor without stealing native caret placement from text.
4640 event.preventDefault();
4641 setIsComposerFocused(true);
4642 scheduleComposerFocus();
4643 }
4644 }}
3567 onFocusCapture={(event) => {4645 onFocusCapture={(event) => {
3568 const activeElement = event.target;4646 const activeElement = event.target;
4647 if (composerControlsInStrip && isInsideRestingComposerControlScope(activeElement)) {
4648 return;
4649 }
3569 if (4650 if (
3570 isComposerCollapsedMobile &&4651 isComposerCollapsedMobile &&
3571 activeElement instanceof HTMLElement &&4652 activeElement instanceof HTMLElement &&
...@@ -3573,6 +4654,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3573,6 +4654,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3573 ) {4654 ) {
3574 return;4655 return;
3575 }4656 }
4657 // Focus returning from another window or tab lands on the element
4658 // that already held it, which is not a request to expand a
4659 // scroll-collapsed composer.
4660 if (!windowRefocusInFlightRef.current) {
4661 setIsComposerScrollCollapsed(false);
4662 }
3576 if (composerBlurFrameRef.current !== null) {4663 if (composerBlurFrameRef.current !== null) {
3577 window.cancelAnimationFrame(composerBlurFrameRef.current);4664 window.cancelAnimationFrame(composerBlurFrameRef.current);
3578 composerBlurFrameRef.current = null;4665 composerBlurFrameRef.current = null;
...@@ -3582,13 +4669,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3582,13 +4669,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3582 onBlurCapture={() => {4669 onBlurCapture={() => {
3583 scheduleComposerCollapseCheck();4670 scheduleComposerCollapseCheck();
3584 }}4671 }}
3585 onDragEnterCapture={composerMentionDragHandlers.onDragEnter}4672 onDragEnterCapture={(event) => {
3586 onDragOverCapture={composerMentionDragHandlers.onDragOver}4673 if (isInsideRestingComposerControlScope(event.target)) return;
3587 onDragLeaveCapture={onComposerMentionDragLeaveCapture}4674 composerMentionDragHandlers.onDragEnter(event);
3588 onDropCapture={composerMentionDragHandlers.onDrop}4675 }}
4676 onDragOverCapture={(event) => {
4677 if (isInsideRestingComposerControlScope(event.target)) return;
4678 composerMentionDragHandlers.onDragOver(event);
4679 }}
4680 onDragLeaveCapture={(event) => {
4681 if (isInsideRestingComposerControlScope(event.target)) return;
4682 onComposerMentionDragLeaveCapture(event);
4683 }}
4684 onDropCapture={(event) => {
4685 if (isInsideRestingComposerControlScope(event.target)) return;
4686 composerMentionDragHandlers.onDrop(event);
4687 }}
3589 className="mx-auto w-full min-w-0 max-w-3xl"4688 className="mx-auto w-full min-w-0 max-w-3xl"
3590 data-chat-composer-form="true"4689 data-chat-composer-form="true"
3591 >4690 >
4691 {composerControlsInStrip && restingControlsHost
4692 ? createPortal(
4693 <div
4694 ref={restingComposerControlsRef}
4695 data-chat-composer-resting-controls="true"
4696 aria-hidden={restingControlsVisible ? undefined : true}
4697 inert={restingControlsVisible ? undefined : true}
4698 className={cn(
4699 "relative flex w-max min-w-0 max-w-full items-center gap-1 font-normal text-muted-foreground/70 [&_button]:text-xs!",
4700 !restingControlsVisible && "invisible",
4701 )}
4702 >
4703 {composerControls}
4704 </div>,
4705 restingControlsHost,
4706 )
4707 : null}
3592 <ComposerBanner.Dock>4708 <ComposerBanner.Dock>
3593 <ComposerBanner.Column>4709 <ComposerBanner.Column>
3594 <ComposerBannerStack4710 <ComposerBannerStack
...@@ -3746,7 +4862,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3746,7 +4862,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3746 ) : null}4862 ) : null}
3747 </ComposerBanner.Dock>4863 </ComposerBanner.Dock>
3748 <div className="relative">4864 <div className="relative">
3749 <ComposerSurface.Main className={composerProviderState.composerFrameClassName}>4865 <ComposerSurface.Main
4866 ref={composerMainSurfaceRef}
4867 className={composerProviderState.composerFrameClassName}
4868 >
3750 <div4869 <div
3751 ref={composerSurfaceRef}4870 ref={composerSurfaceRef}
3752 data-chat-composer-surface="true"4871 data-chat-composer-surface="true"
...@@ -3762,6 +4881,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3762,6 +4881,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3762 <div className="flex items-center justify-between gap-2 px-3 py-2">4881 <div className="flex items-center justify-between gap-2 px-3 py-2">
3763 <button4882 <button
3764 type="button"4883 type="button"
4884 data-chat-composer-transition-prompt="true"
3765 className={cn(4885 className={cn(
3766 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",4886 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
3767 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())4887 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
...@@ -3778,8 +4898,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3778,8 +4898,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3778 : prompt.trim() ||4898 : prompt.trim() ||
3779 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}4899 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
3780 </button>4900 </button>
4901 {collapsedComposerImagePreviews}
3781 <button4902 <button
3782 type="button"4903 type="button"
4904 data-chat-composer-transition-actions="true"
3783 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"4905 className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
3784 disabled={collapsedComposerPrimaryActionDisabled}4906 disabled={collapsedComposerPrimaryActionDisabled}
3785 aria-label={collapsedComposerPrimaryActionLabel}4907 aria-label={collapsedComposerPrimaryActionLabel}
...@@ -3804,11 +4926,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3804,11 +4926,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38044926
3805 <div4927 <div
3806 ref={setComposerMenuAnchor}4928 ref={setComposerMenuAnchor}
4929 data-chat-composer-body="true"
3807 className={cn(4930 className={cn(
3808 "relative px-3 pb-2 sm:px-4",4931 "relative px-3 pb-2 sm:px-4",
3809 "pt-3.5 sm:pt-4",4932 "pt-3.5 sm:pt-4",
3810 isComposerApprovalState && "pb-3 sm:pb-4",4933 isComposerApprovalState && "pb-3 sm:pb-4",
3811 isComposerCollapsedMobile && "hidden",4934 isComposerCollapsedMobile && "hidden",
4935 isComposerResting && "py-2 sm:py-2",
3812 )}4936 )}
3813 >4937 >
3814 {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && (4938 {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && (
...@@ -3904,25 +5028,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3904,25 +5028,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3904 !isComposerApprovalState &&5028 !isComposerApprovalState &&
3905 pendingUserInputs.length === 0 &&5029 pendingUserInputs.length === 0 &&
3906 (composerVideos.length > 0 ||5030 (composerVideos.length > 0 ||
3907 composerImages.some(5031 (!isComposerResting && standaloneComposerImages.length > 0)) && (
3908 (image) =>
3909 !composerPreviewAnnotations.some((annotation) => annotation.id === image.id),
3910 )) && (
3911 <div className="mb-3 flex flex-wrap gap-2">5032 <div className="mb-3 flex flex-wrap gap-2">
3912 {composerImages5033 {!isComposerResting &&
3913 .filter(5034 standaloneComposerImages.map((image) => {
3914 (image) =>
3915 !composerPreviewAnnotations.some(
3916 (annotation) => annotation.id === image.id,
3917 ),
3918 )
3919 .map((image) => {
3920 const upload = supportsAttachmentUploads5035 const upload = supportsAttachmentUploads
3921 ? uploadsByImageId[image.id]5036 ? uploadsByImageId[image.id]
3922 : undefined;5037 : undefined;
3923 return (5038 return (
3924 <div5039 <div
3925 key={image.id}5040 key={image.id}
5041 data-chat-composer-expanded-image="true"
3926 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"5042 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
3927 >5043 >
3928 {image.previewUrl ? (5044 {image.previewUrl ? (
...@@ -4172,7 +5288,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4172,7 +5288,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4172 </div>5288 </div>
4173 )}5289 )}
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 >
4176 <ComposerPromptEditor5298 <ComposerPromptEditor
4177 editorRef={composerEditorRef}5299 editorRef={composerEditorRef}
4178 value={5300 value={
...@@ -4189,9 +5311,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4189,9 +5311,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4189 : []5311 : []
4190 }5312 }
4191 skills={selectedProviderSkills}5313 skills={selectedProviderSkills}
4192 {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}5314 containerClassName={cn(isComposerResting && "min-w-0 flex-1")}
5315 className={cn(
5316 showMobilePendingAnswerActions && "max-sm:pb-11",
5317 isComposerResting &&
5318 "max-h-8 min-h-8 overflow-hidden whitespace-nowrap! leading-8",
5319 )}
5320 placeholderClassName={cn(
5321 isComposerResting &&
5322 "flex items-center overflow-hidden whitespace-nowrap leading-8",
5323 )}
4193 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}5324 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
4194 onChange={onPromptChange}5325 onChange={onPromptChange}
5326 onVisibleSelectionChange={expandComposerForEditorChange}
4195 onCommandKeyDown={onComposerCommandKey}5327 onCommandKeyDown={onComposerCommandKey}
4196 onPageScrollKeyDown={onPageScrollKeyDown}5328 onPageScrollKeyDown={onPageScrollKeyDown}
4197 onPageScrollKeyUp={onPageScrollKeyUp}5329 onPageScrollKeyUp={onPageScrollKeyUp}
...@@ -4215,6 +5347,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4215,6 +5347,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4215 }5347 }
4216 disabled={isConnecting || isComposerApprovalState || projectSelectionRequired}5348 disabled={isConnecting || isComposerApprovalState || projectSelectionRequired}
4217 />5349 />
5350 {isComposerResting ? collapsedComposerImagePreviews : null}
4218 {showMobilePendingAnswerActions ? (5351 {showMobilePendingAnswerActions ? (
4219 <div5352 <div
4220 data-chat-composer-mobile-pending-actions="true"5353 data-chat-composer-mobile-pending-actions="true"
...@@ -4260,94 +5393,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4260,94 +5393,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4260 pendingUserInputs.length > 0 && "pt-2",5393 pendingUserInputs.length > 0 && "pt-2",
4261 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",5394 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
4262 showMobilePendingAnswerActions && "hidden sm:flex",5395 showMobilePendingAnswerActions && "hidden sm:flex",
5396 isComposerResting &&
5397 "absolute bottom-px right-px z-10 h-12 w-auto gap-0 py-0 sm:gap-0 sm:py-0",
4263 )}5398 )}
4264 >5399 >
4265 <div5400 <div
4266 ref={composerFooterControlsRef}5401 ref={composerFooterControlsRef}
5402 data-chat-composer-controls="left"
4267 data-chat-composer-footer-controls="true"5403 data-chat-composer-footer-controls="true"
4268 className="-m-1 -ms-3.5 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 ps-3.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"5404 className={cn(
4269 >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",
4270 {noProviderAvailable ? (5406 isComposerResting && "hidden",
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 </>
4339 )}5407 )}
5408 >
5409 {composerControlsInStrip ? null : composerControls}
4340 </div>5410 </div>
43415411
4342 {/* Right side: send / stop button */}5412 {/* Right side: send / stop button */}
4343 <div5413 <div
4344 data-chat-composer-actions="right"5414 data-chat-composer-actions="right"
5415 data-chat-composer-transition-actions="true"
4345 data-chat-composer-primary-actions-compact={5416 data-chat-composer-primary-actions-compact={
4346 isComposerPrimaryActionsCompact ? "true" : "false"5417 isComposerPrimaryActionsCompact ? "true" : "false"
4347 }5418 }
4348 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"5419 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
4349 >5420 >
4350 {fileStagingLimit !== null && pendingUserInputs.length === 0 ? (5421 {showComposerAttachAction ? (
4351 <>5422 <>
4352 <input5423 <input
4353 ref={attachmentInputRef}5424 ref={attachmentInputRef}
...@@ -4381,7 +5452,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4381,7 +5452,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4381 </>5452 </>
4382 ) : null}5453 ) : null}
4383 <ComposerFooterPrimaryActions5454 <ComposerFooterPrimaryActions
4384 compact={isComposerPrimaryActionsCompact}5455 compact={isComposerResting || isComposerPrimaryActionsCompact}
4385 activeContextWindow={5456 activeContextWindow={
4386 settings.contextWindowMeterEnabled ? activeContextWindow : null5457 settings.contextWindowMeterEnabled ? activeContextWindow : null
4387 }5458 }
...@@ -4402,8 +5473,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4402,8 +5473,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4402 }5473 }
4403 isPreparingWorktree={isPreparingWorktree}5474 isPreparingWorktree={isPreparingWorktree}
4404 hasSendableContent={composerSendState.hasSendableContent}5475 hasSendableContent={composerSendState.hasSendableContent}
4405 preserveComposerFocusOnPointerDown={isMobileViewport}5476 preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting}
4406 showSendWhileRunning={isMobileViewport}5477 showSendWhileRunning={isMobileViewport}
5478 showSecondaryStatus={!isComposerResting}
4407 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}5479 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
4408 onInterrupt={handleInterruptPrimaryAction}5480 onInterrupt={handleInterruptPrimaryAction}
4409 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}5481 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+10-6
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";1import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";
2import { memo, type ReactNode } from "react";2import { memo, type ReactNode } from "react";
3import { EllipsisIcon } from "lucide-react";3import { EllipsisIcon } from "lucide-react";
4import { Button } from "../ui/button";
5import {4import {
6 Menu,5 Menu,
7 MenuPopup,6 MenuPopup,
...@@ -10,30 +9,35 @@ import {...@@ -10,30 +9,35 @@ import {
10 MenuSeparator as MenuDivider,9 MenuSeparator as MenuDivider,
11 MenuTrigger,10 MenuTrigger,
12} from "../ui/menu";11} from "../ui/menu";
12import { ComposerControl, ComposerControlIcon } from "./ComposerControl";
13import { composerFloatingLayerProps } from "./composerEventScope";
1314
14export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {15export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
15 interactionMode: ProviderInteractionMode;16 interactionMode: ProviderInteractionMode;
16 runtimeMode: RuntimeMode;17 runtimeMode: RuntimeMode;
17 showInteractionModeToggle: boolean;18 showInteractionModeToggle: boolean;
18 traitsMenuContent?: ReactNode;19 traitsMenuContent?: ReactNode;
20 size?: "sm" | "xs";
19 onToggleInteractionMode: () => void;21 onToggleInteractionMode: () => void;
20 onRuntimeModeChange: (mode: RuntimeMode) => void;22 onRuntimeModeChange: (mode: RuntimeMode) => void;
21}) {23}) {
24 const size = props.size ?? "sm";
25
22 return (26 return (
23 <Menu>27 <Menu>
24 <MenuTrigger28 <MenuTrigger
25 render={29 render={
26 <Button30 <ComposerControl
27 size="sm"31 size={size}
28 variant="ghost"32 variant="ghost"
29 className="shrink-0 px-2 text-muted-foreground/70 hover:text-foreground/80"33 className={size === "xs" ? "shrink-0" : "shrink-0 px-2"}
30 aria-label="More composer controls"34 aria-label="More composer controls"
31 />35 />
32 }36 }
33 >37 >
34 <EllipsisIcon aria-hidden="true" className="size-4" />38 <ComposerControlIcon icon={EllipsisIcon} size={size} />
35 </MenuTrigger>39 </MenuTrigger>
36 <MenuPopup align="start">40 <MenuPopup align="start" {...composerFloatingLayerProps}>
37 {props.traitsMenuContent ? (41 {props.traitsMenuContent ? (
38 <>42 <>
39 {props.traitsMenuContent}43 {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";...@@ -4,19 +4,37 @@ import { ChevronDownIcon, type LucideIcon } from "lucide-react";
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { Button } from "../ui/button";5import { Button } from "../ui/button";
6import { SelectTrigger } from "../ui/select";6import { 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
8const composerControlClassName =19const 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
11export function ComposerControl({25export function ComposerControl({
12 className,26 className,
13 size = "sm",27 size = "sm",
14 variant = "ghost",28 variant = "ghost",
15 ...props29 ...props
16}: ComponentProps<typeof Button>) {30}: ComposerControlProps) {
17 return (31 return (
18 <Button32 <Button
19 className={cn(composerControlClassName, className)}33 className={cn(
34 composerControlClassName,
35 size === "xs" ? restingComposerControlClassName : expandedComposerControlClassName,
36 className,
37 )}
20 size={size}38 size={size}
21 variant={variant}39 variant={variant}
22 {...props}40 {...props}
...@@ -28,41 +46,77 @@ export function ComposerControlIcon({...@@ -28,41 +46,77 @@ export function ComposerControlIcon({
28 icon: Icon,46 icon: Icon,
29 className,47 className,
30 opticalSize = "default",48 opticalSize = "default",
49 size = "sm",
31}: {50}: {
32 icon: LucideIcon;51 icon: LucideIcon;
33 className?: string | undefined;52 className?: string | undefined;
34 opticalSize?: "default" | "large";53 opticalSize?: "default" | "large";
54 size?: ComposerControlSize;
35}) {55}) {
36 return (56 return (
37 <Icon57 <Icon
38 aria-hidden="true"58 aria-hidden="true"
39 className={cn("shrink-0", opticalSize === "large" ? "size-4.5" : "size-4", className)}59 className={cn(
60 "shrink-0",
61 size === "xs" ? "size-3" : opticalSize === "large" ? "size-4.5" : "size-4",
62 className,
63 )}
40 data-composer-control-icon64 data-composer-control-icon
41 />65 />
42 );66 );
43}67}
4468
45export function ComposerControlChevron() {69export function ComposerControlChevron({
70 className,
71 size = "sm",
72}: {
73 className?: string;
74 size?: ComposerControlSize;
75} = {}) {
46 return (76 return (
47 <ChevronDownIcon77 <ChevronDownIcon
48 aria-hidden="true"78 aria-hidden="true"
49 className="-mx-0.5 size-3.5 shrink-0 text-icon-muted"79 className={cn(
80 "shrink-0",
81 size === "xs" ? "size-3 text-current opacity-50" : "size-3.5 text-icon-muted",
82 className,
83 )}
50 data-composer-control-chevron84 data-composer-control-chevron
51 strokeWidth={2.25}85 strokeWidth={2.25}
52 />86 />
53 );87 );
54}88}
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
56export function ComposerSelectControl({106export function ComposerSelectControl({
57 className,107 className,
58 size = "sm",108 size = "sm",
59 variant = "ghost",109 variant = "ghost",
60 ...props110 ...props
61}: ComponentProps<typeof SelectTrigger>) {111}: ComposerSelectControlProps) {
62 return (112 return (
63 <SelectTrigger113 <SelectTrigger
64 className={cn(composerControlClassName, className)}114 className={cn(
65 icon={<ComposerControlChevron />}115 composerControlClassName,
116 size === "xs" ? restingComposerControlClassName : expandedComposerControlClassName,
117 className,
118 )}
119 icon={<ComposerControlChevron size={size} />}
66 size={size}120 size={size}
67 variant={variant}121 variant={variant}
68 {...props}122 {...props}
apps/web/src/components/chat/ComposerPrimaryActions.tsx+2-1
...@@ -6,6 +6,7 @@ import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../Sideb...@@ -6,6 +6,7 @@ import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../Sideb
6import { Button } from "../ui/button";6import { Button } from "../ui/button";
7import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";7import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
8import { Spinner } from "../ui/spinner";8import { Spinner } from "../ui/spinner";
9import { composerFloatingLayerProps } from "./composerEventScope";
910
10interface PendingActionState {11interface PendingActionState {
11 questionIndex: number;12 questionIndex: number;
...@@ -205,7 +206,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -205,7 +206,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
205 >206 >
206 <ChevronDownIcon className="size-3.5" />207 <ChevronDownIcon className="size-3.5" />
207 </MenuTrigger>208 </MenuTrigger>
208 <MenuPopup align="end" side="top">209 <MenuPopup align="end" side="top" {...composerFloatingLayerProps}>
209 <MenuItem210 <MenuItem
210 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}211 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
211 onClick={() => void onImplementPlanInNewThread()}212 onClick={() => void onImplementPlanInNewThread()}
apps/web/src/components/chat/ComposerSurface.tsx+1-1
...@@ -13,7 +13,7 @@ function Shell({...@@ -13,7 +13,7 @@ function Shell({
13 data-slot="composer-shell"13 data-slot="composer-shell"
14 data-with-context={contextStrip || undefined}14 data-with-context={contextStrip || undefined}
15 className={cn(15 className={cn(
16 "group/composer-surface relative isolate mx-auto w-full max-w-3xl",16 "@container/composer-surface group/composer-surface relative isolate mx-auto w-full max-w-3xl",
17 "[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]",17 "[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]",
18 "dark:[--chat-composer-glass-surface:var(--surface-raised)] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]",18 "dark:[--chat-composer-glass-surface:var(--surface-raised)] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]",
19 "[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)]",19 "[html[data-theme-id]_&]:[--chat-composer-glass-surface:var(--app-theme-surface-raised)] [html[data-theme-id]_&]:[--chat-composer-outline:var(--app-theme-toolbar-border)]",
apps/web/src/components/chat/ContextWindowMeter.tsx+2
...@@ -3,6 +3,7 @@ import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/con...@@ -3,6 +3,7 @@ import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/con
3import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";3import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
4import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic";4import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic";
5import { Minimize2Icon } from "lucide-react";5import { Minimize2Icon } from "lucide-react";
6import { composerFloatingLayerProps } from "./composerEventScope";
67
7function formatPercentage(value: number | null): string | null {8function formatPercentage(value: number | null): string | null {
8 if (value === null || !Number.isFinite(value)) {9 if (value === null || !Number.isFinite(value)) {
...@@ -83,6 +84,7 @@ export function ContextWindowMeter(props: {...@@ -83,6 +84,7 @@ export function ContextWindowMeter(props: {
83 }84 }
84 />85 />
85 <PopoverPopup86 <PopoverPopup
87 {...composerFloatingLayerProps}
86 tooltipStyle88 tooltipStyle
87 side="top"89 side="top"
88 align="end"90 align="end"
apps/web/src/components/chat/MessagesTimeline.test.tsx+45-8
...@@ -3,7 +3,7 @@ import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";...@@ -3,7 +3,7 @@ import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
3import { createRef, type ReactNode, type Ref } from "react";3import { createRef, type ReactNode, type Ref } from "react";
4import { renderToStaticMarkup } from "react-dom/server";4import { renderToStaticMarkup } from "react-dom/server";
5import { beforeAll, describe, expect, it, vi } from "vite-plus/test";5import { 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
8vi.mock("@legendapp/list/react", async () => {8vi.mock("@legendapp/list/react", async () => {
9 const legendListTestId = "legend-list";9 const legendListTestId = "legend-list";
...@@ -16,23 +16,34 @@ vi.mock("@legendapp/list/react", async () => {...@@ -16,23 +16,34 @@ vi.mock("@legendapp/list/react", async () => {
16 ListFooterComponent?: ReactNode;16 ListFooterComponent?: ReactNode;
17 anchoredEndSpace?: {17 anchoredEndSpace?: {
18 anchorIndex: number;18 anchorIndex: number;
19 anchorMaxSize?: number;
20 anchorOffset?: number;
21 onReady?: (info: { anchorIndex: number }) => void;
19 };22 };
20 maintainScrollAtEnd?:23 contentInsetEndAdjustment?: number;
24 className?: string;
25 maintainScrollAtEnd?: boolean | MaintainScrollAtEndOptions;
26 maintainVisibleContentPosition?:
21 | boolean27 | boolean
22 | {28 | {
23 animated?: boolean;29 data?: boolean;
24 on?: {30 size?: boolean;
25 dataChange?: boolean;31 shouldRestorePosition?: (item: { id: string }) => boolean;
26 itemLayout?: boolean;
27 layout?: boolean;
28 };
29 };32 };
30 ref?: Ref<LegendListRef>;33 ref?: Ref<LegendListRef>;
31 }) => {34 }) => {
35 if (props.anchoredEndSpace) {
36 props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex });
37 }
32 return (38 return (
33 <div39 <div
34 data-testid={legendListTestId}40 data-testid={legendListTestId}
35 data-anchor-index={props.anchoredEndSpace?.anchorIndex}41 data-anchor-index={props.anchoredEndSpace?.anchorIndex}
42 data-anchor-max-size={props.anchoredEndSpace?.anchorMaxSize}
43 data-anchor-offset={props.anchoredEndSpace?.anchorOffset}
44 data-anchor-on-ready={Boolean(props.anchoredEndSpace?.onReady)}
45 data-content-inset-end={props.contentInsetEndAdjustment}
46 data-class-name={props.className}
36 data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "enabled" : undefined}47 data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "enabled" : undefined}
37 data-maintain-scroll-at-end-animated={48 data-maintain-scroll-at-end-animated={
38 typeof props.maintainScrollAtEnd === "object"49 typeof props.maintainScrollAtEnd === "object"
...@@ -44,6 +55,11 @@ vi.mock("@legendapp/list/react", async () => {...@@ -44,6 +55,11 @@ vi.mock("@legendapp/list/react", async () => {
44 ? props.maintainScrollAtEnd.on?.dataChange55 ? props.maintainScrollAtEnd.on?.dataChange
45 : undefined56 : undefined
46 }57 }
58 data-maintain-scroll-at-end-footer-layout={
59 typeof props.maintainScrollAtEnd === "object"
60 ? props.maintainScrollAtEnd.on?.footerLayout
61 : undefined
62 }
47 data-maintain-scroll-at-end-item-layout={63 data-maintain-scroll-at-end-item-layout={
48 typeof props.maintainScrollAtEnd === "object"64 typeof props.maintainScrollAtEnd === "object"
49 ? props.maintainScrollAtEnd.on?.itemLayout65 ? props.maintainScrollAtEnd.on?.itemLayout
...@@ -54,6 +70,26 @@ vi.mock("@legendapp/list/react", async () => {...@@ -54,6 +70,26 @@ vi.mock("@legendapp/list/react", async () => {
54 ? props.maintainScrollAtEnd.on?.layout70 ? props.maintainScrollAtEnd.on?.layout
55 : undefined71 : undefined
56 }72 }
73 data-maintain-visible-content-position={
74 typeof props.maintainVisibleContentPosition === "object"
75 ? "object"
76 : props.maintainVisibleContentPosition
77 }
78 data-maintain-visible-content-position-data={
79 typeof props.maintainVisibleContentPosition === "object"
80 ? props.maintainVisibleContentPosition.data
81 : undefined
82 }
83 data-maintain-visible-content-position-size={
84 typeof props.maintainVisibleContentPosition === "object"
85 ? props.maintainVisibleContentPosition.size
86 : undefined
87 }
88 data-maintain-visible-content-position-restore={
89 typeof props.maintainVisibleContentPosition === "object"
90 ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition)
91 : undefined
92 }
57 >93 >
58 {props.ListHeaderComponent}94 {props.ListHeaderComponent}
59 {props.data.map((item) => (95 {props.data.map((item) => (
...@@ -704,6 +740,7 @@ describe("MessagesTimeline", () => {...@@ -704,6 +740,7 @@ describe("MessagesTimeline", () => {
704 expect(markup).toContain('data-maintain-scroll-at-end="enabled"');740 expect(markup).toContain('data-maintain-scroll-at-end="enabled"');
705 expect(markup).toContain('data-maintain-scroll-at-end-animated="false"');741 expect(markup).toContain('data-maintain-scroll-at-end-animated="false"');
706 expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"');742 expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"');
743 expect(markup).toContain('data-maintain-scroll-at-end-footer-layout="false"');
707 expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"');744 expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"');
708 expect(markup).toContain('data-maintain-scroll-at-end-layout="true"');745 expect(markup).toContain('data-maintain-scroll-at-end-layout="true"');
709 expect(markup).toContain('data-user-message-collapsed="true"');746 expect(markup).toContain('data-user-message-collapsed="true"');
apps/web/src/components/chat/MessagesTimeline.tsx+26-20
...@@ -44,7 +44,11 @@ import {...@@ -44,7 +44,11 @@ import {
44 type MouseEvent,44 type MouseEvent,
45 type ReactNode,45 type ReactNode,
46} from "react";46} from "react";
47import { LegendList, type LegendListRef } from "@legendapp/list/react";47import {
48 LegendList,
49 type LegendListRef,
50 type MaintainScrollAtEndOptions,
51} from "@legendapp/list/react";
48import { FileDiff } from "@pierre/diffs/react";52import { FileDiff } from "@pierre/diffs/react";
49import {53import {
50 deriveTimelineEntries,54 deriveTimelineEntries,
...@@ -103,10 +107,7 @@ import {...@@ -103,10 +107,7 @@ import {
103import { ProposedPlanCard } from "./ProposedPlanCard";107import { ProposedPlanCard } from "./ProposedPlanCard";
104import { ChangedFilesCard } from "./ChangedFilesTree";108import { ChangedFilesCard } from "./ChangedFilesTree";
105import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";109import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
106import {110import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring";
107 CHAT_TIMELINE_ANCHOR_OFFSET,
108 keepTimelineEndVisibleAfterOverlayGrowth,
109} from "./timelineScrollAnchoring";
110import { MessageCopyButton } from "./MessageCopyButton";111import { MessageCopyButton } from "./MessageCopyButton";
111import { PierreEntryIcon } from "./PierreEntryIcon";112import { PierreEntryIcon } from "./PierreEntryIcon";
112import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";113import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
...@@ -255,16 +256,27 @@ function TimelineLoadEarlierHeader({...@@ -255,16 +256,27 @@ function TimelineLoadEarlierHeader({
255 </div>256 </div>
256 );257 );
257}258}
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}
259const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];267const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
260const TIMELINE_MAINTAIN_SCROLL_AT_END = {268const TIMELINE_MAINTAIN_SCROLL_AT_END = {
261 animated: false,269 animated: false,
262 on: {270 on: {
263 dataChange: true,271 dataChange: true,
272 // Composer inset changes must not move already-visible messages. New
273 // rows and row growth still keep live-follow pinned through the other
274 // triggers below.
275 footerLayout: false,
264 itemLayout: true,276 itemLayout: true,
265 layout: true,277 layout: true,
266 },278 },
267} as const;279} as const satisfies MaintainScrollAtEndOptions;
268280
269// ---------------------------------------------------------------------------281// ---------------------------------------------------------------------------
270// Props (public API)282// Props (public API)
...@@ -381,8 +393,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -381,8 +393,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
381 const disclosureAnchorKeyRef = useRef<string | null>(null);393 const disclosureAnchorKeyRef = useRef<string | null>(null);
382 const disclosureSettleFrameRef = useRef<number | null>(null);394 const disclosureSettleFrameRef = useRef<number | null>(null);
383 const disclosureSettleSecondFrameRef = useRef<number | null>(null);395 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
384 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment);
385
386 useEffect(() => {396 useEffect(() => {
387 return () => {397 return () => {
388 if (disclosureSettleFrameRef.current !== null) {398 if (disclosureSettleFrameRef.current !== null) {
...@@ -533,15 +543,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -533,15 +543,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
533 onExpandTurn: expandCitedTurn,543 onExpandTurn: expandCitedTurn,
534 onManualNavigation,544 onManualNavigation,
535 });545 });
536 useLayoutEffect(() => {
537 keepTimelineEndVisibleAfterOverlayGrowth({
538 timeline: listRef.current,
539 previousOverlayHeight: previousContentInsetEndAdjustmentRef.current,
540 overlayHeight: contentInsetEndAdjustment,
541 followingEnd: liveFollowEnabled && anchorMessageId === null && !citationPositioning,
542 });
543 previousContentInsetEndAdjustmentRef.current = contentInsetEndAdjustment;
544 }, [anchorMessageId, citationPositioning, contentInsetEndAdjustment, listRef, liveFollowEnabled]);
545 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);546 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
546 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);547 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
547 const handleAnchorReady = useCallback(548 const handleAnchorReady = useCallback(
...@@ -561,6 +562,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -561,6 +562,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
561 );562 );
562 return config ? { ...config, onReady: handleAnchorReady } : undefined;563 return config ? { ...config, onReady: handleAnchorReady } : undefined;
563 }, [anchorMessageId, handleAnchorReady, rows]);564 }, [anchorMessageId, handleAnchorReady, rows]);
565 const timelineListFooter = useMemo(
566 () => <TimelineListFooter composerInset={anchoredEndSpace ? 0 : contentInsetEndAdjustment} />,
567 [anchoredEndSpace, contentInsetEndAdjustment],
568 );
564569
565 const handleScroll = useCallback(() => {570 const handleScroll = useCallback(() => {
566 const state = listRef.current?.getState?.();571 const state = listRef.current?.getState?.();
...@@ -736,7 +741,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -736,7 +741,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
736 {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})}741 {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})}
737 onLoad={onCitationListLoad}742 onLoad={onCitationListLoad}
738 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}743 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
739 contentInsetEndAdjustment={contentInsetEndAdjustment}744 contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0}
740 maintainScrollAtEnd={745 maintainScrollAtEnd={
741 citationPositioning ||746 citationPositioning ||
742 anchoredEndSpace ||747 anchoredEndSpace ||
...@@ -748,6 +753,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -748,6 +753,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
748 maintainVisibleContentPosition={753 maintainVisibleContentPosition={
749 citationPositioning ? false : maintainVisibleContentPosition754 citationPositioning ? false : maintainVisibleContentPosition
750 }755 }
756 maintainScrollAtEndThreshold={1}
751 onScroll={handleScroll}757 onScroll={handleScroll}
752 className={cn(758 className={cn(
753 "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",759 "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
...@@ -766,7 +772,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -766,7 +772,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
766 TIMELINE_LIST_HEADER772 TIMELINE_LIST_HEADER
767 )773 )
768 }774 }
769 ListFooterComponent={TIMELINE_LIST_FOOTER}775 ListFooterComponent={timelineListFooter}
770 />776 />
771 <TimelineMinimap777 <TimelineMinimap
772 items={minimapItems}778 items={minimapItems}
apps/web/src/components/chat/ProviderModelPicker.test.tsx+20
...@@ -99,4 +99,24 @@ describe("ProviderModelPicker", () => {...@@ -99,4 +99,24 @@ describe("ProviderModelPicker", () => {
99 expect(markup).toContain("Fallback model");99 expect(markup).toContain("Fallback model");
100 expect(markup).not.toContain(">missing-model<");100 expect(markup).not.toContain(">missing-model<");
101 });101 });
102
103 it("keeps instance initials visible in the resting trigger", () => {
104 const activeEntry = providerEntry("codex_personal", "codex");
105 const markup = renderToStaticMarkup(
106 <ProviderModelPicker
107 activeInstanceId={activeEntry.instanceId}
108 model="gpt-5"
109 lockedProvider={null}
110 instanceEntries={[providerEntry("codex", "codex"), activeEntry]}
111 modelOptionsByInstance={new Map()}
112 size="xs"
113 onInstanceModelChange={() => {}}
114 />,
115 );
116
117 expect(markup).toContain(">CP</span>");
118 expect(markup).toContain("size-4");
119 expect(markup).toContain("h-3");
120 expect(markup).toContain("text-[7px]");
121 });
102});122});
apps/web/src/components/chat/ProviderModelPicker.tsx+28-8
...@@ -18,7 +18,12 @@ import {...@@ -18,7 +18,12 @@ import {
18 getTriggerDisplayModelName,18 getTriggerDisplayModelName,
19} from "./providerIconUtils";19} from "./providerIconUtils";
20import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances";20import { 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
23export const ProviderModelPicker = memo(function ProviderModelPicker(props: {28export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
24 /**29 /**
...@@ -34,7 +39,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -34,7 +39,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
34 keybindings?: ResolvedKeybindingsConfig;39 keybindings?: ResolvedKeybindingsConfig;
35 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>;40 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>;
36 activeProviderIconClassName?: string;41 activeProviderIconClassName?: string;
42 instanceIndicatorBackground?: string;
43 size?: ComposerControlSize;
37 compact?: boolean;44 compact?: boolean;
45 isComposerOwned?: boolean;
38 disabled?: boolean;46 disabled?: boolean;
39 terminalOpen?: boolean;47 terminalOpen?: boolean;
40 open?: boolean;48 open?: boolean;
...@@ -47,6 +55,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -47,6 +55,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
47}) {55}) {
48 const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false);56 const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false);
49 const isMenuOpen = props.open ?? uncontrolledIsMenuOpen;57 const isMenuOpen = props.open ?? uncontrolledIsMenuOpen;
58 const size = props.size ?? "sm";
5059
51 // Resolve the active instance entry by exact routing key. The composer60 // Resolve the active instance entry by exact routing key. The composer
52 // resolves fallbacks before rendering this component; if the selected61 // resolves fallbacks before rendering this component; if the selected
...@@ -148,6 +157,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -148,6 +157,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
148 <ComposerControl157 <ComposerControl
149 aria-label={props.triggerAriaLabel}158 aria-label={props.triggerAriaLabel}
150 variant={props.triggerVariant ?? "ghost"}159 variant={props.triggerVariant ?? "ghost"}
160 size={size}
151 data-chat-provider-model-picker="true"161 data-chat-provider-model-picker="true"
152 className={cn(162 className={cn(
153 "min-w-0 justify-between whitespace-nowrap",163 "min-w-0 justify-between whitespace-nowrap",
...@@ -158,7 +168,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -158,7 +168,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
158 />168 />
159 }169 }
160 >170 >
161 <span className="flex min-w-0 flex-1 items-center gap-1.5">171 <span
172 className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")}
173 >
162 {activeEntry ? (174 {activeEntry ? (
163 <ProviderInstanceIcon175 <ProviderInstanceIcon
164 driverKind={activeEntry.driverKind}176 driverKind={activeEntry.driverKind}
...@@ -167,15 +179,22 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -167,15 +179,22 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
167 showBadge={showInstanceBadge}179 showBadge={showInstanceBadge}
168 className="size-4"180 className="size-4"
169 iconClassName={cn("size-4", props.activeProviderIconClassName)}181 iconClassName={cn("size-4", props.activeProviderIconClassName)}
170 indicatorBackground="var(--contrast-input)"182 indicatorBackground={props.instanceIndicatorBackground ?? "var(--contrast-input)"}
171 badgeClassName={cn(183 badgeClassName={cn(
172 "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3",184 "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-[7px]",
173 "px-0.5 text-[7px]",185 size === "xs" && "shadow-none",
174 )}186 )}
175 />187 />
176 ) : null}188 ) : null}
177 <Tooltip>189 <Tooltip>
178 <TooltipTrigger render={<span className="min-w-0 flex-1 overflow-hidden truncate" />}>190 <TooltipTrigger
191 render={
192 <span
193 className="min-w-0 flex-1 overflow-hidden truncate"
194 data-chat-provider-model-picker-label="true"
195 />
196 }
197 >
179 {triggerTitle}198 {triggerTitle}
180 </TooltipTrigger>199 </TooltipTrigger>
181 <TooltipPopup side="top">{triggerLabel}</TooltipPopup>200 <TooltipPopup side="top">{triggerLabel}</TooltipPopup>
...@@ -187,13 +206,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -187,13 +206,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
187 ) : null}206 ) : null}
188 </span>207 </span>
189 <span aria-hidden="true" className="flex items-center">208 <span aria-hidden="true" className="flex items-center">
190 <ComposerControlChevron />209 <ComposerControlChevron size={size} />
191 </span>210 </span>
192 </PopoverTrigger>211 </PopoverTrigger>
193 <PopoverPopup212 <PopoverPopup
213 {...(props.isComposerOwned ? composerFloatingLayerProps : {})}
194 align="start"214 align="start"
195 className="before:hidden [--viewport-inline-padding:0]"215 className="before:hidden [--viewport-inline-padding:0]"
196 viewportClassName="!overflow-hidden rounded-[calc(var(--radius-lg)-1px)] p-0 [clip-path:inset(0_round_calc(var(--radius-lg)-1px))]"216 viewportClassName="overflow-hidden! rounded-[calc(var(--radius-lg)-1px)] p-0 [clip-path:inset(0_round_calc(var(--radius-lg)-1px))]"
197 >217 >
198 <ModelPickerContent218 <ModelPickerContent
199 activeInstanceId={activeInstanceId}219 activeInstanceId={activeInstanceId}
apps/web/src/components/chat/TraitsPicker.tsx+25-6
...@@ -32,7 +32,13 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore";...@@ -32,7 +32,13 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore";
32import { getProviderModelCapabilities } from "../../providerModels";32import { getProviderModelCapabilities } from "../../providerModels";
33import { cn } from "~/lib/utils";33import { cn } from "~/lib/utils";
34import { Badge } from "../ui/badge";34import { 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
37type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;43type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;
3844
...@@ -274,6 +280,7 @@ export interface TraitsMenuContentProps {...@@ -274,6 +280,7 @@ export interface TraitsMenuContentProps {
274 planModeEnabled: boolean;280 planModeEnabled: boolean;
275 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];281 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
276 triggerClassName?: string;282 triggerClassName?: string;
283 isComposerOwned?: boolean;
277}284}
278285
279export const TraitsMenuContent = memo(function TraitsMenuContentImpl({286export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
...@@ -540,8 +547,13 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -540,8 +547,13 @@ export const TraitsPicker = memo(function TraitsPicker({
540 planModeEnabled,547 planModeEnabled,
541 triggerVariant,548 triggerVariant,
542 triggerClassName,549 triggerClassName,
550 isComposerOwned,
551 size = "sm",
543 ...persistence552 ...persistence
544}: TraitsMenuContentProps & TraitsPersistence) {553}: TraitsMenuContentProps &
554 TraitsPersistence & {
555 size?: ComposerControlSize;
556 }) {
545 const [isMenuOpen, setIsMenuOpen] = useState(false);557 const [isMenuOpen, setIsMenuOpen] = useState(false);
546 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =558 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =
547 getTraitsSectionVisibility({559 getTraitsSectionVisibility({
...@@ -577,6 +589,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -577,6 +589,7 @@ export const TraitsPicker = memo(function TraitsPicker({
577 <>589 <>
578 <ComposerControlIcon590 <ComposerControlIcon
579 icon={ZapIcon}591 icon={ZapIcon}
592 size={size}
580 className={cn(593 className={cn(
581 "fill-current opacity-80",594 "fill-current opacity-80",
582 provider === "claudeAgent" ? "text-[#d97757]" : "text-foreground",595 provider === "claudeAgent" ? "text-[#d97757]" : "text-foreground",
...@@ -599,6 +612,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -599,6 +612,7 @@ export const TraitsPicker = memo(function TraitsPicker({
599 render={612 render={
600 <ComposerControl613 <ComposerControl
601 variant={triggerVariant ?? "ghost"}614 variant={triggerVariant ?? "ghost"}
615 size={size}
602 className={cn(616 className={cn(
603 isCodexStyle617 isCodexStyle
604 ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap sm:max-w-48"618 ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap sm:max-w-48"
...@@ -609,20 +623,25 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -609,20 +623,25 @@ export const TraitsPicker = memo(function TraitsPicker({
609 }623 }
610 >624 >
611 {isCodexStyle ? (625 {isCodexStyle ? (
612 <span className="flex min-w-0 w-full items-center gap-1.5 overflow-hidden">626 <span
627 className={cn(
628 "flex min-w-0 w-full items-center overflow-hidden",
629 size === "xs" ? "gap-1" : "gap-1.5",
630 )}
631 >
613 {fastModeIcon}632 {fastModeIcon}
614 <span className="min-w-0 truncate">{triggerLabel}</span>633 <span className="min-w-0 truncate">{triggerLabel}</span>
615 <ComposerControlChevron />634 <ComposerControlChevron size={size} />
616 </span>635 </span>
617 ) : (636 ) : (
618 <>637 <>
619 {fastModeIcon}638 {fastModeIcon}
620 <span>{triggerLabel}</span>639 <span>{triggerLabel}</span>
621 <ComposerControlChevron />640 <ComposerControlChevron size={size} />
622 </>641 </>
623 )}642 )}
624 </MenuTrigger>643 </MenuTrigger>
625 <MenuPopup align="start">644 <MenuPopup align="start" {...(isComposerOwned ? composerFloatingLayerProps : {})}>
626 <TraitsMenuContent645 <TraitsMenuContent
627 provider={provider}646 provider={provider}
628 {...(instanceId ? { instanceId } : {})}647 {...(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 {...@@ -12,10 +12,13 @@ import {
12 isClaudeUltrathinkPrompt,12 isClaudeUltrathinkPrompt,
13 normalizeModelSlug,13 normalizeModelSlug,
14} from "@t3tools/shared/model";14} from "@t3tools/shared/model";
15import type { VariantProps } from "class-variance-authority";
15import type { ReactNode } from "react";16import type { ReactNode } from "react";
1617
18import type { buttonVariants } from "../ui/button";
17import type { DraftId } from "../../composerDraftStore";19import type { DraftId } from "../../composerDraftStore";
18import { getProviderModelCapabilities } from "../../providerModels";20import { getProviderModelCapabilities } from "../../providerModels";
21import type { ComposerControlSize } from "./ComposerControl";
19import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker";22import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
2023
21export type ComposerProviderStateInput = {24export type ComposerProviderStateInput = {
...@@ -49,6 +52,10 @@ type TraitsRenderInput = {...@@ -49,6 +52,10 @@ type TraitsRenderInput = {
49 prompt: string;52 prompt: string;
50 onPromptChange: (prompt: string) => void;53 onPromptChange: (prompt: string) => void;
51 planModeEnabled: boolean;54 planModeEnabled: boolean;
55 size?: ComposerControlSize;
56 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
57 triggerClassName?: string;
58 isComposerOwned?: boolean;
52};59};
5360
54export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState {61export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState {
...@@ -123,6 +130,10 @@ function renderTraitsControl(...@@ -123,6 +130,10 @@ function renderTraitsControl(
123 prompt,130 prompt,
124 onPromptChange,131 onPromptChange,
125 planModeEnabled,132 planModeEnabled,
133 size,
134 triggerVariant,
135 triggerClassName,
136 isComposerOwned,
126 } = input;137 } = input;
127 const hasTarget = threadRef !== undefined || draftId !== undefined;138 const hasTarget = threadRef !== undefined || draftId !== undefined;
128 if (139 if (
...@@ -150,6 +161,10 @@ function renderTraitsControl(...@@ -150,6 +161,10 @@ function renderTraitsControl(
150 prompt={prompt}161 prompt={prompt}
151 onPromptChange={onPromptChange}162 onPromptChange={onPromptChange}
152 planModeEnabled={planModeEnabled}163 planModeEnabled={planModeEnabled}
164 {...(size !== undefined ? { size } : {})}
165 {...(triggerVariant !== undefined ? { triggerVariant } : {})}
166 {...(triggerClassName !== undefined ? { triggerClassName } : {})}
167 {...(isComposerOwned ? { isComposerOwned } : {})}
153 />168 />
154 );169 );
155}170}
apps/web/src/components/chat/composerScrollGesture.test.ts created+94
...@@ -0,0 +1,94 @@
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 @@...@@ -1,9 +1,5 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import {2import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring";
3 getAnchoredTurnMetrics,
4 getRowBottom,
5 keepTimelineEndVisibleAfterOverlayGrowth,
6} from "./timelineScrollAnchoring";
73
8function buildState({4function buildState({
9 positions,5 positions,
...@@ -26,33 +22,6 @@ function buildState({...@@ -26,33 +22,6 @@ function buildState({
26}22}
2723
28describe("timeline scroll anchoring", () => {24describe("timeline scroll anchoring", () => {
29 it("keeps the live edge visible when the composer overlay grows", () => {
30 const scrollToEnd = vi.fn();
31
32 keepTimelineEndVisibleAfterOverlayGrowth({
33 timeline: { scrollToEnd },
34 previousOverlayHeight: 120,
35 overlayHeight: 180,
36 followingEnd: true,
37 });
38
39 expect(scrollToEnd).toHaveBeenCalledOnce();
40 expect(scrollToEnd).toHaveBeenCalledWith({ animated: false });
41 });
42
43 it("leaves the scroll position alone while the user reads history", () => {
44 const scrollToEnd = vi.fn();
45
46 keepTimelineEndVisibleAfterOverlayGrowth({
47 timeline: { scrollToEnd },
48 previousOverlayHeight: 120,
49 overlayHeight: 180,
50 followingEnd: false,
51 });
52
53 expect(scrollToEnd).not.toHaveBeenCalled();
54 });
55
56 it("measures row bottoms from LegendList row position and size", () => {25 it("measures row bottoms from LegendList row position and size", () => {
57 const state = buildState({26 const state = buildState({
58 positions: [0, 120],27 positions: [0, 120],
apps/web/src/components/chat/timelineScrollAnchoring.ts-16
...@@ -22,22 +22,6 @@ export interface AnchoredTurnMetrics {...@@ -22,22 +22,6 @@ export interface AnchoredTurnMetrics {
22 readonly scrollDeltaToRevealEnd: number;22 readonly scrollDeltaToRevealEnd: number;
23}23}
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
41export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null {25export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null {
42 const top = state.positionAtIndex(index);26 const top = state.positionAtIndex(index);
43 const height = state.sizeAtIndex(index);27 const height = state.sizeAtIndex(index);
apps/web/src/components/composerFooterLayout.test.ts+163
...@@ -3,10 +3,31 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,10 +3,31 @@ import { describe, expect, it } from "vite-plus/test";
3import {3import {
4 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,4 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
5 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,5 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
6 getRestingComposerImagePreviewCounts,
7 resolveRestingComposerControlsLayout,
8 shouldAnimateComposerRestingTransition,
6 shouldUseCompactComposerPrimaryActions,9 shouldUseCompactComposerPrimaryActions,
7 shouldUseCompactComposerFooter,10 shouldUseCompactComposerFooter,
11 shouldUseRestingComposerLayout,
8} from "./composerFooterLayout";12} 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
10describe("shouldUseCompactComposerFooter", () => {31describe("shouldUseCompactComposerFooter", () => {
11 it("stays expanded without a measured width", () => {32 it("stays expanded without a measured width", () => {
12 expect(shouldUseCompactComposerFooter(null)).toBe(false);33 expect(shouldUseCompactComposerFooter(null)).toBe(false);
...@@ -50,3 +71,145 @@ describe("shouldUseCompactComposerPrimaryActions", () => {...@@ -50,3 +71,145 @@ describe("shouldUseCompactComposerPrimaryActions", () => {
50 ).toBe(false);71 ).toBe(false);
51 });72 });
52});73});
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 @@...@@ -1,5 +1,17 @@
1export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620;1export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620;
2export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780;2export 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
4export function shouldUseCompactComposerFooter(16export function shouldUseCompactComposerFooter(
5 width: number | null,17 width: number | null,
...@@ -11,6 +23,36 @@ export function shouldUseCompactComposerFooter(...@@ -11,6 +23,36 @@ export function shouldUseCompactComposerFooter(
11 return width !== null && width < breakpoint;23 return width !== null && width < breakpoint;
12}24}
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
14export function shouldUseCompactComposerPrimaryActions(56export function shouldUseCompactComposerPrimaryActions(
15 width: number | null,57 width: number | null,
16 options?: { hasWideActions?: boolean },58 options?: { hasWideActions?: boolean },
...@@ -20,3 +62,38 @@ export function shouldUseCompactComposerPrimaryActions(...@@ -20,3 +62,38 @@ export function shouldUseCompactComposerPrimaryActions(
20 }62 }
21 return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX;63 return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX;
22}64}
65
66/**
67 * Decide how many trailing resting control blocks move into the overflow
68 * menu, and whether the cluster can show at all, from natural widths.
69 *
70 * Trailing blocks hide before the model picker shrinks. Once they are all in
71 * the overflow menu, the picker may contract to its minimum readable width;
72 * below that the whole cluster hides rather than clipping.
73 */
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...@@ -51,6 +51,18 @@ On web and desktop, HEIC and HEIF photos are automatically converted to JPEG whe
51the composer or paste them into a message. On iOS, selecting them from **Photo Library** also51the composer or paste them into a message. On iOS, selecting them from **Photo Library** also
52converts them to JPEG. The 10 MB image limit applies to the converted photo.52converts 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
54On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic,66On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic,
55GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list67GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
56when starting a thread or changing an existing thread's model.68when starting a thread or changing an existing thread's model.