Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ import {
} from "lexical";

import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
import { isComposerPromptEditorBeyondMinimumHeight } from "./ComposerPromptEditor";

describe("isComposerPromptEditorBeyondMinimumHeight", () => {
it("reports the first physical height increase beyond the editor minimum", () => {
expect(isComposerPromptEditorBeyondMinimumHeight({ clientHeight: 70 }, 70)).toBe(false);
expect(isComposerPromptEditorBeyondMinimumHeight({ clientHeight: 71 }, 70)).toBe(false);
expect(isComposerPromptEditorBeyondMinimumHeight({ clientHeight: 92 }, 70)).toBe(true);
});
});

class TestClipboardEvent extends Event {
readonly clipboardData: DataTransfer;
Expand Down
62 changes: 60 additions & 2 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -876,14 +876,25 @@ export interface ComposerPromptEditorHandle {
};
}

export function isComposerPromptEditorBeyondMinimumHeight(
element: Pick<HTMLElement, "clientHeight">,
minimumHeight: number,
): boolean {
// Ignore sub-pixel rounding so the affordance does not flicker at the
// editor's minimum-height boundary.
return element.clientHeight - minimumHeight > 1;
}

interface ComposerPromptEditorProps {
value: string;
cursor: number;
terminalContexts: ReadonlyArray<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
disabled: boolean;
expanded?: boolean;
placeholder: string;
className?: string;
onBeyondMinimumHeightChange?: (beyondMinimumHeight: boolean) => void;
onRemoveTerminalContext: (contextId: string) => void;
onChange: (
nextValue: string,
Expand Down Expand Up @@ -1531,8 +1542,10 @@ function ComposerPromptEditorInner({
terminalContexts,
skills,
disabled,
expanded = false,
placeholder,
className,
onBeyondMinimumHeightChange,
onRemoveTerminalContext,
onChange,
onCommandKeyDown,
Expand Down Expand Up @@ -1571,6 +1584,46 @@ function ComposerPromptEditorInner({
editor.setEditable(!disabled);
}, [disabled, editor]);

useLayoutEffect(() => {
if (!onBeyondMinimumHeightChange || expanded) return;

const rootElement = editor.getRootElement();
if (!rootElement) return;

let animationFrame: number | null = null;
const measureHeight = () => {
animationFrame = null;
const minimumHeight = Number.parseFloat(window.getComputedStyle(rootElement).minHeight);
onBeyondMinimumHeightChange(
Number.isFinite(minimumHeight) &&
isComposerPromptEditorBeyondMinimumHeight(rootElement, minimumHeight),
);
};
const scheduleMeasurement = () => {
if (animationFrame !== null) return;
animationFrame = window.requestAnimationFrame(measureHeight);
};

measureHeight();
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasurement);
resizeObserver?.observe(rootElement);
const mutationObserver = new MutationObserver(scheduleMeasurement);
mutationObserver.observe(rootElement, {
childList: true,
characterData: true,
subtree: true,
});

return () => {
if (animationFrame !== null) {
window.cancelAnimationFrame(animationFrame);
}
resizeObserver?.disconnect();
mutationObserver.disconnect();
};
}, [editor, expanded, onBeyondMinimumHeightChange]);

useLayoutEffect(() => {
const normalizedCursor = clampCollapsedComposerCursor(value, cursor);
const previousSnapshot = snapshotRef.current;
Expand Down Expand Up @@ -1747,14 +1800,15 @@ function ComposerPromptEditorInner({

return (
<ComposerTerminalContextActionsContext value={terminalContextActions}>
<div className="composer-editor-surface relative">
<div className={cn("composer-editor-surface relative", expanded && "h-full min-h-0")}>
<PlainTextPlugin
contentEditable={
<ContentEditable
className={cn(
// The size comes from .composer-editor-surface so Settings -> Appearance
// can drive it; keep everything else here.
"block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none",
"block max-h-64 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none",
expanded && "h-full min-h-0 max-h-none",
className,
)}
data-testid="composer-editor"
Expand Down Expand Up @@ -1793,8 +1847,10 @@ export function ComposerPromptEditor({
terminalContexts,
skills,
disabled,
expanded = false,
placeholder,
className,
onBeyondMinimumHeightChange,
onRemoveTerminalContext,
onChange,
onCommandKeyDown,
Expand Down Expand Up @@ -1831,11 +1887,13 @@ export function ComposerPromptEditor({
terminalContexts={terminalContexts}
skills={skills}
disabled={disabled}
expanded={expanded}
placeholder={placeholder}
onRemoveTerminalContext={onRemoveTerminalContext}
onChange={onChange}
onPaste={onPaste}
editorRef={editorRef}
{...(onBeyondMinimumHeightChange ? { onBeyondMinimumHeightChange } : {})}
{...(onCommandKeyDown ? { onCommandKeyDown } : {})}
{...(className ? { className } : {})}
/>
Expand Down
79 changes: 76 additions & 3 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
expandCollapsedComposerCursor,
replaceTextRange,
shouldAcceptPromptSuggestionOnTab,
shouldCollapseExpandedComposer,
shouldSubmitComposerOnEnter,
} from "../../composer-logic";
import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";
Expand Down Expand Up @@ -196,6 +197,8 @@ import { toastManager } from "../ui/toast";
import {
BotIcon,
CircleAlertIcon,
Maximize2Icon,
Minimize2Icon,
PencilRulerIcon,
type LucideIcon,
LockIcon,
Expand Down Expand Up @@ -950,6 +953,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const [isComposerExpanded, setIsComposerExpanded] = useState(false);
const [isComposerExpandAvailable, setIsComposerExpandAvailable] = useState(false);
const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
Expand Down Expand Up @@ -1402,10 +1407,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
setComposerHighlightedItemId(null);
setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
setIsComposerExpanded(false);
setIsComposerExpandAvailable(false);
dragDepthRef.current = 0;
setIsDragOverComposer(false);
}, [draftId, activeThreadId, promptRef]);

useEffect(() => {
if (isMobileViewport) {
setIsComposerExpanded(false);
}
}, [isMobileViewport]);

// ------------------------------------------------------------------
// Footer compact layout observation
// ------------------------------------------------------------------
Expand Down Expand Up @@ -1531,6 +1544,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
cursorAdjacentToMention: boolean,
terminalContextIds: string[],
) => {
if (shouldCollapseExpandedComposer(nextPrompt)) {
setIsComposerExpanded(false);
setIsComposerExpandAvailable(false);
}
if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {
setComposerCursor(nextCursor);
setComposerTrigger(
Expand Down Expand Up @@ -1828,6 +1845,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
return;
}
onSend(event);
setIsComposerExpanded(false);
if (shouldBlurMobileComposerOnSubmit()) {
blurMobileComposerAfterSend();
}
Expand Down Expand Up @@ -1864,6 +1882,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
});
}, []);

const handleComposerBeyondMinimumHeightChange = useCallback((beyondMinimumHeight: boolean) => {
setIsComposerExpandAvailable(beyondMinimumHeight);
if (!beyondMinimumHeight) {
setIsComposerExpanded(false);
}
}, []);

// ------------------------------------------------------------------
// Callbacks: command key
// ------------------------------------------------------------------
Expand Down Expand Up @@ -2674,6 +2699,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
<div
className={cn(
"group rounded-[22px] p-px transition-colors duration-200",
isComposerExpanded && "h-[min(48rem,calc(100dvh-5rem))]",
composerProviderState.composerFrameClassName,
)}
onDragEnter={onComposerDragEnter}
Expand All @@ -2690,6 +2716,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
className={cn(
"rounded-[20px] transition-[background-color] duration-200",
isComposerExpanded && "flex h-full min-h-0 flex-col",
isDragOverComposer ? "bg-accent/45 ring-1 ring-primary/70" : null,
projectSelectionRequired ? "opacity-75" : null,
composerProviderState.composerSurfaceClassName,
Expand Down Expand Up @@ -2868,6 +2895,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
ref={setComposerMenuAnchor}
className={cn(
"relative px-3 pb-2 sm:px-4",
isComposerExpanded && "flex min-h-0 flex-1 flex-col",
hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
isComposerCollapsedMobile && "hidden",
)}
Expand Down Expand Up @@ -2910,6 +2938,37 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
</ComposerCommandMenuLayer>
)}

{!isMobileViewport && (isComposerExpandAvailable || isComposerExpanded) ? (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className={cn(
"absolute right-4 z-10 text-secondary-label hover:text-foreground",
hasComposerHeader ? "top-2.5 sm:top-3" : "top-3.5 sm:top-4",
)}
aria-label={isComposerExpanded ? "Collapse" : "Expand"}
aria-expanded={isComposerExpanded}
onPointerDown={(event) => event.preventDefault()}
onClick={() => setIsComposerExpanded((expanded) => !expanded)}
>
{isComposerExpanded ? (
<Minimize2Icon className="size-3.5" />
) : (
<Maximize2Icon className="size-3.5" />
)}
</Button>
}
/>
<TooltipPopup side="left">
{isComposerExpanded ? "Collapse" : "Expand"}
</TooltipPopup>
</Tooltip>
) : null}

{!isComposerCollapsedMobile &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
Expand Down Expand Up @@ -2961,7 +3020,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
(image) =>
!composerPreviewAnnotations.some((annotation) => annotation.id === image.id),
) && (
<div className="mb-3 flex flex-wrap gap-2">
<div
className={cn(
"mb-3 flex flex-wrap gap-2",
!isMobileViewport &&
(isComposerExpandAvailable || isComposerExpanded) &&
"pr-8",
)}
>
{composerImages
.filter(
(image) =>
Expand Down Expand Up @@ -3032,7 +3098,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
</div>
)}

<div className="relative">
<div className={cn("relative", isComposerExpanded && "min-h-0 flex-1")}>
<ComposerPromptEditor
editorRef={composerEditorRef}
value={
Expand All @@ -3049,7 +3115,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
: []
}
skills={selectedProviderStatus?.skills ?? []}
{...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
expanded={isComposerExpanded}
className={cn(
showMobilePendingAnswerActions && "max-sm:pb-11",
!isMobileViewport &&
(isComposerExpandAvailable || isComposerExpanded) &&
"composer-editor-expand-control-visible pr-8",
)}
onBeyondMinimumHeightChange={handleComposerBeyondMinimumHeightChange}
onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
onChange={onPromptChange}
onCommandKeyDown={onComposerCommandKey}
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
parseStandaloneComposerSlashCommand,
replaceTextRange,
shouldAcceptPromptSuggestionOnTab,
shouldCollapseExpandedComposer,
shouldSubmitComposerOnEnter,
} from "./composer-logic";
import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
Expand Down Expand Up @@ -59,6 +60,14 @@ describe("shouldAcceptPromptSuggestionOnTab", () => {
});
});

describe("shouldCollapseExpandedComposer", () => {
it("collapses only when the editor value becomes empty", () => {
expect(shouldCollapseExpandedComposer("")).toBe(true);
expect(shouldCollapseExpandedComposer(" ")).toBe(false);
expect(shouldCollapseExpandedComposer("still editing")).toBe(false);
});
});

describe("detectComposerTrigger", () => {
it("detects @path trigger at cursor", () => {
const text = "Please check @src/com";
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export function shouldAcceptPromptSuggestionOnTab(input: {
);
}

export function shouldCollapseExpandedComposer(prompt: string): boolean {
return prompt.length === 0;
}

const isInlineTokenSegment = (
segment:
| { type: "text"; text: string }
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1703,6 +1703,13 @@ code {
font-size: var(--font-size-prompt, 0.875rem);
}

/* The expand control owns the composer's top-right corner. In Chromium-based
web and desktop clients, start the native editor scrollbar below it instead
of shifting the control away from the corner. */
.composer-editor-expand-control-visible::-webkit-scrollbar-track {
margin-block-start: 2rem;
}

/* Touch browsers zoom the page when a focused field is under 16px, so keep
the floor there regardless of the preference. Gated on a coarse pointer:
the zoom quirk does not exist on desktop, where a narrow window must not
Expand Down
Loading