diff --git a/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx b/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx index 529dfe1c4f..9c918d4415 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx @@ -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 }: { @@ -15,6 +16,7 @@ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, r const { locale } = useWorkspaceI18n(); const zh = locale === "zh-CN"; const root = useRef(null); + useComposerMenuSpace(root); const [open, setOpen] = useState(false); const [discovery, setDiscovery] = useState(null); const [loading, setLoading] = useState(false); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx b/apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx index e0753df0dd..f446b5f743 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx @@ -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 }: { @@ -34,6 +35,7 @@ export function ComposerRuntimeControls({ agents, agentId, goalId, original, mod export function ComposerActions({ children }: { children: ReactNode }) { const { locale } = useWorkspaceI18n(); const root = useRef(null); + useComposerMenuSpace(root); useEffect(() => { const close = (event: PointerEvent | KeyboardEvent) => { if (!root.current?.open) return; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 68a90578a8..0028f0a23f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -2222,7 +2222,7 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-composer-runtime .personal-chat-model-menu > summary > span { display: flex; align-items: baseline; gap: 6px; } .personal-composer-runtime .personal-chat-model-menu > summary small { flex: none; } .personal-composer-runtime .personal-chat-model-menu > summary .personal-model-current { font-size: var(--text-caption); } -.personal-composer-runtime .personal-chat-model-panel { top: auto; bottom: calc(100% + 12px); } +.personal-composer-runtime .personal-chat-model-panel { top: auto; bottom: calc(100% + 12px); max-height: min(480px, 70dvh, var(--composer-menu-space, 70dvh)); } .personal-composer-runtime .personal-chat-model-panel .personal-select-listbox { bottom: auto; } .personal-model-next-heading { display: flex; align-items: center; gap: 6px; } .personal-model-options { display: grid; gap: 8px; min-width: 0; margin: 0; padding: 0; border: 0; } @@ -2233,7 +2233,7 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-composer-actions > summary::-webkit-details-marker { display: none; } .personal-composer-actions > summary:hover, .personal-composer-runtime .personal-select-trigger:hover, .personal-composer-runtime .personal-chat-model-menu > summary:hover { background: var(--pw-line); color: var(--pw-text); } .personal-composer-actions > summary:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; } -.personal-composer-actions-panel { position: absolute; z-index: 50; bottom: calc(100% + 10px); left: 0; display: grid; gap: 8px; width: min(300px, calc(100vw - 44px)); max-height: min(420px, 65vh); overflow: auto; padding: 8px; border: 1px solid var(--pw-line); border-radius: 8px; background: var(--pw-card); box-shadow: 0 8px 24px rgb(0 0 0 / 9%); } +.personal-composer-actions-panel { position: absolute; z-index: 50; bottom: calc(100% + 10px); left: 0; display: grid; gap: 8px; width: min(300px, calc(100vw - 44px)); max-height: min(420px, 65dvh, var(--composer-menu-space, 65dvh)); overflow: auto; padding: 8px; border: 1px solid var(--pw-line); border-radius: 8px; background: var(--pw-card); box-shadow: 0 8px 24px rgb(0 0 0 / 9%); } .personal-composer-actions:not([open]) > .personal-composer-actions-panel { display: none; } .personal-composer-actions-panel button { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px; border: 0; border-radius: 4px; background: transparent; color: var(--pw-text); font: inherit; text-align: left; cursor: pointer; } .personal-composer-actions-panel button:hover { background: var(--pw-line); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/use-composer-menu-space.ts b/apps/presentation/dashboard/src/features/personal-workspace/use-composer-menu-space.ts new file mode 100644 index 0000000000..3337e77f99 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/use-composer-menu-space.ts @@ -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) { + useEffect(() => { + const menu = root.current; + const composer = menu?.closest(".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]); +} diff --git a/examples/personal-workspace-browser/agent-model-selection.mjs b/examples/personal-workspace-browser/agent-model-selection.mjs index b34716cb0a..7c03280982 100644 --- a/examples/personal-workspace-browser/agent-model-selection.mjs +++ b/examples/personal-workspace-browser/agent-model-selection.mjs @@ -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 => { @@ -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;