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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ContextHelp } from "./context-help";
import { chatModelControls } from "./chat-model-controls";
import { useWorkspaceI18n } from "./i18n";
import { WorkspaceSelect } from "./workspace-select";
import { useComposerMenuSpace } from "./use-composer-menu-space";

/** Reported models stay visible; missing observations belong in the settings disclosure. */
export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, readback, onModel, onEffort, onOpenDefaults }: {
Expand All @@ -15,6 +16,7 @@ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, r
const { locale } = useWorkspaceI18n();
const zh = locale === "zh-CN";
const root = useRef<HTMLDetailsElement>(null);
useComposerMenuSpace(root);
const [open, setOpen] = useState(false);
const [discovery, setDiscovery] = useState<AgentModelDiscovery | null>(null);
const [loading, setLoading] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { WorkspaceAgentOption } from "./personal-workspace-model";
import { ChatModelMenu } from "./chat-model-menu";
import { useWorkspaceI18n } from "./i18n";
import { WorkspaceSelect } from "./workspace-select";
import { useComposerMenuSpace } from "./use-composer-menu-space";

/** Existing send controls keep their Session/configuration owners. */
export function ComposerRuntimeControls({ agents, agentId, goalId, original, model, effort, readback, onAgent, onModel, onEffort, onDefaults }: {
Expand Down Expand Up @@ -34,6 +35,7 @@ export function ComposerRuntimeControls({ agents, agentId, goalId, original, mod
export function ComposerActions({ children }: { children: ReactNode }) {
const { locale } = useWorkspaceI18n();
const root = useRef<HTMLDetailsElement>(null);
useComposerMenuSpace(root);
useEffect(() => {
const close = (event: PointerEvent | KeyboardEvent) => {
if (!root.current?.open) return;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect, type RefObject } from "react";

/** Menus above the whole input must fit the space left by a growing draft. */
export function useComposerMenuSpace(root: RefObject<HTMLDetailsElement | null>) {
useEffect(() => {
const menu = root.current;
const composer = menu?.closest<HTMLElement>(".personal-channel-composer");
if (!menu || !composer) return;
let stop: (() => void) | undefined;
const toggle = () => {
stop?.();
stop = undefined;
if (!menu.open) return;
const place = () => {
const top = window.visualViewport?.offsetTop ?? 0;
// Reserve the panel gap and a margin at the visible viewport's top.
menu.style.setProperty("--composer-menu-space", `${Math.max(0, composer.getBoundingClientRect().top - top - 24)}px`);
};
const observer = new ResizeObserver(place);
observer.observe(composer);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
window.visualViewport?.addEventListener("resize", place);
window.visualViewport?.addEventListener("scroll", place);
place();
stop = () => {
observer.disconnect();
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
window.visualViewport?.removeEventListener("resize", place);
window.visualViewport?.removeEventListener("scroll", place);
};
};
menu.addEventListener("toggle", toggle);
toggle();
return () => { stop?.(); menu.removeEventListener("toggle", toggle); };
}, [root]);
}
35 changes: 33 additions & 2 deletions examples/personal-workspace-browser/agent-model-selection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ export const agentModelSelectionScenario = {
await page.route("**/api/chat/agent-model-options", route => { discoveryCount += 1; return route.fulfill({ json: {
ok: true, schema_version: "loopx_chat_agent_model_options_v1", agent_id: "codex", supported: true,
model: { id: "engine", label: "Model", selected: "safe", options: [
{ value: "safe", label: "Safe", reasoning_efforts: ["low", "high"] },
{ value: "vendor/model fast", label: "Fast", reasoning_efforts: ["low", "high"] },
{ value: "safe", label: "Safe", reasoning_efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] },
{ value: "vendor/model fast", label: "Fast", reasoning_efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] },
] },
} }); });
await page.route("**/api/chat/sessions", async route => {
Expand Down Expand Up @@ -212,6 +212,37 @@ export const agentModelSelectionScenario = {
assert.ok((await readback.boundingBox()).height < 30, "speed and reasoning do not wrap into a vertical column");
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.keyboard.press("Escape");
const longDraft = "中文与 English 的长草稿保留。\n".repeat(6);
const checkMenuSpace = async selector => {
const bounds = await page.locator(selector).boundingBox();
const frame = await page.locator(".personal-channel-composer").boundingBox();
assert.ok(bounds && frame && bounds.y >= 8 && bounds.y + bounds.height <= frame.y,
"A growing draft leaves the complete menu above the input and inside the visible viewport");
};
await page.setViewportSize({width: 390, height: 568});
await draft.fill(longDraft);
await openSettings();
const effortControl = page.getByRole("combobox", {name: "选择思考深度"});
await effortControl.click();
await checkMenuSpace(".personal-chat-model-panel");
await page.keyboard.press("End");
const lastChoice = page.getByRole("option", {name: "ultra", exact: true});
await lastChoice.waitFor();
assert.ok(await lastChoice.evaluate(node => {
const bounds = node.getBoundingClientRect();
return bounds.top >= 0 && bounds.bottom <= innerHeight;
}), "Keyboard scrolling keeps the last native effort choice reachable");
await page.keyboard.press("Escape");
assert.equal(await effortControl.evaluate(node => node === document.activeElement), true);
assert.equal(await menu.evaluate(node => node.open), true, "Escape first closes the nested choices");
await page.setViewportSize({width: 390, height: 480});
await checkMenuSpace(".personal-chat-model-panel");
await page.keyboard.press("Escape");
await actionTrigger.click();
await checkMenuSpace(".personal-composer-actions-panel");
await page.keyboard.press("Escape");
assert.equal(await draft.inputValue(), longDraft, "Menu resize and dismissal retain the complete draft");
await page.setViewportSize({width: 390, height: 844});
await draft.fill("保存失败也不能丢掉草稿。");
await openSettings();
failSave = true;
Expand Down
Loading