Skip to content
Open
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
16 changes: 16 additions & 0 deletions components/ChatInput.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,22 @@ test("renders the shared field model selector as a disabled gray control", () =>
test("caps an upward menu to the visible space above its anchor", () => {
assert.equal(getUpwardMenuMaxHeight(343, 36), 299);
assert.equal(getUpwardMenuMaxHeight(40, 36), 0);
// Composer sitting under a 36px top bar with only ~160px of air: a 400px
// file list would paint through the bar and hide the leading matches.
assert.equal(getUpwardMenuMaxHeight(200, 36), 156);
assert.ok(getUpwardMenuMaxHeight(200, 36) < 400);
});

test("file mention menu applies the measured upward height cap", () => {
const source = readFileSync(new URL("./ChatInput.tsx", import.meta.url), "utf8");
const start = source.indexOf("{atMenuOpen && atQuery !== null && (() => {");
assert.notEqual(start, -1);
const block = source.slice(start, start + 4000);
assert.match(block, /ref=\{atMenuRef\}/);
assert.match(block, /min\(48vh, 400px, \$\{atMenuMaxHeight\}px\)/);
assert.match(block, /flexDirection: "column"/);
assert.match(block, /minHeight: 0/);
assert.equal(block.includes("maxHeight: \"min(48vh, 400px)\""), false);
});

test("compresses large images while preserving small images and GIFs", async () => {
Expand Down
100 changes: 64 additions & 36 deletions components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,45 @@ function getVisibleTopBoundary(element: HTMLElement): number {
return visibleTop;
}

function subscribeUpwardMenuMaxHeight(
menu: HTMLElement,
onChange: (height: number) => void,
): () => void {
let frameId: number | null = null;
const update = () => {
frameId = null;
onChange(getUpwardMenuMaxHeight(
menu.getBoundingClientRect().bottom,
getVisibleTopBoundary(menu),
));
};
const scheduleUpdate = () => {
if (frameId !== null) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(update);
};

update();
const parent = menu.parentElement;
const anchorObserver = typeof ResizeObserver === "undefined" || !parent
? null
: new ResizeObserver(scheduleUpdate);
if (parent) anchorObserver?.observe(parent);
const viewport = window.visualViewport;
viewport?.addEventListener("resize", scheduleUpdate);
viewport?.addEventListener("scroll", scheduleUpdate);
window.addEventListener("resize", scheduleUpdate);
window.addEventListener("scroll", scheduleUpdate, true);

return () => {
anchorObserver?.disconnect();
viewport?.removeEventListener("resize", scheduleUpdate);
viewport?.removeEventListener("scroll", scheduleUpdate);
window.removeEventListener("resize", scheduleUpdate);
window.removeEventListener("scroll", scheduleUpdate, true);
if (frameId !== null) cancelAnimationFrame(frameId);
};
}

const THINKING_LEVELS = ["auto", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
const THINKING_LEVEL_DESC_KEYS: Record<typeof THINKING_LEVELS[number], string> = {
auto: "chat.thinkingUseDefault", off: "chat.thinkingOff", minimal: "chat.thinkingMinimal", low: "chat.thinkingLow",
Expand Down Expand Up @@ -527,6 +566,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
const [slashMenuMaxHeight, setSlashMenuMaxHeight] = useState<number | null>(null);
const [atQuery, setAtQuery] = useState<AtQueryMatch | null>(null);
const [atMenuOpen, setAtMenuOpen] = useState(false);
const [atMenuMaxHeight, setAtMenuMaxHeight] = useState<number | null>(null);
const [atActiveIndex, setAtActiveIndex] = useState(0);
const [imageWarningDismissed, setImageWarningDismissed] = useState(false);
const [historyMenuOpen, setHistoryMenuOpen] = useState(false);
Expand All @@ -553,6 +593,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
const slashCommandsRequestedRef = useRef(false);
const slashMenuRef = useRef<HTMLDivElement>(null);
const slashItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const atMenuRef = useRef<HTMLDivElement>(null);
const atItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const historyItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const fileIndexMetaRef = useRef<{ cwd: string; fetchedAt: number } | null>(null);
Expand Down Expand Up @@ -1430,45 +1471,25 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
setSlashMenuMaxHeight(null);
return;
}

const menu = slashMenuRef.current;
if (!menu) return;

let frameId: number | null = null;
const update = () => {
frameId = null;
const nextHeight = getUpwardMenuMaxHeight(
menu.getBoundingClientRect().bottom,
getVisibleTopBoundary(menu),
);
return subscribeUpwardMenuMaxHeight(menu, (nextHeight) => {
setSlashMenuMaxHeight((current) => current === nextHeight ? current : nextHeight);
};
const scheduleUpdate = () => {
if (frameId !== null) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(update);
};

update();
const anchorObserver = typeof ResizeObserver === "undefined" || !menu.parentElement
? null
: new ResizeObserver(scheduleUpdate);
if (menu.parentElement) anchorObserver?.observe(menu.parentElement);
const viewport = window.visualViewport;
viewport?.addEventListener("resize", scheduleUpdate);
viewport?.addEventListener("scroll", scheduleUpdate);
window.addEventListener("resize", scheduleUpdate);
window.addEventListener("scroll", scheduleUpdate, true);

return () => {
anchorObserver?.disconnect();
viewport?.removeEventListener("resize", scheduleUpdate);
viewport?.removeEventListener("scroll", scheduleUpdate);
window.removeEventListener("resize", scheduleUpdate);
window.removeEventListener("scroll", scheduleUpdate, true);
if (frameId !== null) cancelAnimationFrame(frameId);
};
});
}, [slashMenuOpen, slashQuery]);

useLayoutEffect(() => {
if (!atMenuOpen || atQuery === null) {
setAtMenuMaxHeight(null);
return;
}
const menu = atMenuRef.current;
if (!menu) return;
return subscribeUpwardMenuMaxHeight(menu, (nextHeight) => {
setAtMenuMaxHeight((current) => current === nextHeight ? current : nextHeight);
});
}, [atMenuOpen, atQuery]);

// Build model options: prefer modelList (has provider info), fallback to modelNames
const modelOptions: ModelSelectorOption[] = (() => {
if (modelList && modelList.length > 0) {
Expand Down Expand Up @@ -1953,6 +1974,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
: "";
return (
<div
ref={atMenuRef}
style={{
position: "absolute",
left: 0,
Expand All @@ -1964,7 +1986,12 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
borderRadius: 8,
boxShadow: "0 -6px 20px rgba(0,0,0,0.12)",
overflow: "hidden",
maxHeight: "min(48vh, 400px)",
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
maxHeight: atMenuMaxHeight === null
? "min(48vh, 400px)"
: `min(48vh, 400px, ${atMenuMaxHeight}px)`,
}}
>
<div
Expand All @@ -1977,6 +2004,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
gap: 8,
fontSize: 11,
color: "var(--text-dim)",
flexShrink: 0,
}}
>
<span>
Expand All @@ -1986,7 +2014,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{t("chat.tabEnter")}</span>
</div>
<div style={{ maxHeight: "calc(min(48vh, 400px) - 34px)", overflowY: "auto", padding: 4 }}>
<div style={{ flex: "1 1 auto", minHeight: 0, overflowY: "auto", padding: 4 }}>
{!indexLoading && atMatches.length === 0 ? (
<div style={{ padding: "6px 8px", fontSize: 12, color: "var(--text-dim)" }}>
{needsServerSearch && !serverResultInUse ? t("chat.searching") : t("chat.noMatchingFiles")}
Expand Down
Loading