diff --git a/desktop/frontend-next/perf/README.md b/desktop/frontend-next/perf/README.md index 92506ac8f5..3891c00962 100644 --- a/desktop/frontend-next/perf/README.md +++ b/desktop/frontend-next/perf/README.md @@ -72,6 +72,7 @@ node perf/runningtool.mjs # 还没输出的运行中调用:行能展开、写 node perf/gutterhit.mjs # 分栏拖拽手柄不压在相邻滚动条上:滚动条拇指处按下命中的不是手柄,握把仍可拖开收起的栏 node perf/dock.mjs # 停靠的浏览器列从 pane 顶部起、与导航行并排,取消停靠后行归还(浅/深 × 宽/窄 × 首开/重载) node perf/signals.mjs # 运行分析的信号轨说明:它是什么、为什么标记不能拖(浅/深 × 宽/手机) +node perf/narrow.mjs # 窄窗口 + 1.8 倍缩放:标题栏与输入区的行互不重叠、不越出窗口(浅/深 × 390/320 × 1.8/1.3,另加宽窗口对照) ``` `lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定 diff --git a/desktop/frontend-next/perf/harness.tsx b/desktop/frontend-next/perf/harness.tsx index 4500e7ea4b..9c0f4ed00f 100644 --- a/desktop/frontend-next/perf/harness.tsx +++ b/desktop/frontend-next/perf/harness.tsx @@ -7,6 +7,7 @@ import "../src/styles/dock.css"; import "../src/styles/studio.css"; import "../src/styles/tips.css"; import "../src/styles/waitnote.css"; +import "../src/styles/narrow.css"; import "../src/styles/feedback.css"; import { App } from "../src/ui/App"; import { MockHub } from "../src/port/mock_hub"; diff --git a/desktop/frontend-next/perf/narrow.mjs b/desktop/frontend-next/perf/narrow.mjs new file mode 100644 index 0000000000..8b774ddf2b --- /dev/null +++ b/desktop/frontend-next/perf/narrow.mjs @@ -0,0 +1,133 @@ +// Under a high interface scale the layout's room is the window divided by the zoom, so a phone-width window can leave +// the shell a couple of hundred CSS pixels. Rows that cannot fit must give something up, never paint over each other. +import { chromium } from "playwright"; + +const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html"; +const SHOTS = process.env.NARROW_SHOTS; +const fails = []; +const check = (name, ok, detail = "") => { + console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); + if (!ok) fails.push(name); +}; + +const probe = (page) => + page.evaluate(() => { + const live = (el) => { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden" ? r : null; + }; + const name = (el) => (el.className && String(el.className).split(/\s+/)[0]) || el.tagName; + const overlaps = (parent) => { + const boxes = [...parent.children].map((el) => [el, live(el)]).filter(([, r]) => r); + const hits = []; + for (let i = 0; i < boxes.length; i++) + for (let j = i + 1; j < boxes.length; j++) { + const [a, ra] = boxes[i]; + const [b, rb] = boxes[j]; + const x = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left); + const y = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top); + if (x > 1 && y > 1) hits.push(`${name(a)}/${name(b)}`); + } + return hits; + }; + const outside = (parent) => + [...parent.children].filter((el) => live(el) && live(el).right > innerWidth + 1).map(name); + const bar = document.querySelector(".chrome"); + const row = document.querySelector(".compose .row"); + const tools = document.querySelector(".compose .turntools"); + const crumb = bar.querySelector(".crumb"); + const title = crumb.querySelector("b"); + const clip = crumb.getBoundingClientRect(); + const t = title.getBoundingClientRect(); + return { + bar: { overlaps: overlaps(bar).concat(overlaps(bar.querySelector(".r"))), outside: outside(bar).concat(outside(bar.querySelector(".r"))) }, + row: { overlaps: overlaps(row), outside: outside(row) }, + toolsOverflow: [...tools.children].filter((el) => live(el) && live(el).right > tools.getBoundingClientRect().right + 1).map(name), + titleShown: t.width, + titleOutsideCrumb: t.width > 0 && (t.left < clip.left - 1 || t.right > clip.right + 1), + crumbWidth: clip.width, + room: document.body.clientWidth, + }; + }); + +const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".browser-action"]; + +// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it. +async function reachable(page) { + const more = page.locator(".chrome .r-more"); + if (await more.isVisible()) { + await more.click(); + await page.waitForTimeout(150); + } + return page.evaluate((controls) => { + const out = []; + for (const sel of controls) { + const el = document.querySelector(`.chrome ${sel}`); + if (!el) { + out.push(`${sel} missing`); + continue; + } + const r = el.getBoundingClientRect(); + const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + const inside = r.width > 0 && r.left >= 0 && r.right <= innerWidth + 1; + if (!inside || !(el === top || el.contains(top))) out.push(`${sel}${inside ? " covered" : " off screen"}`); + } + return out; + }, CONTROLS); +} + +const browser = await chromium.launch(); + +// The fold's threshold is derived, not chosen: wherever the bar is not folded the title keeps its floor. +{ + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + const bad = []; + for (let room = 190; room <= 460; room += 10) { + await page.setViewportSize({ width: room, height: 800 }); + await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" }); + await page.waitForSelector(".app"); + await page.waitForTimeout(200); + const w = await page.evaluate(() => document.querySelector(".chrome .crumb").getBoundingClientRect().width); + if (w < 48) bad.push(`${room}:${Math.round(w)}`); + } + check("at every room from 190 to 460 the title keeps its 4em floor", bad.length === 0, bad.join(" ")); + await page.close(); +} +for (const scheme of ["light", "dark"]) { + for (const [width, zoom] of [[390, 1.8], [390, 1.3], [320, 1.8], [1280, 1]]) { + const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" }); + page.setDefaultTimeout(8000); + page.on("pageerror", (e) => fails.push("page error: " + e.message)); + await page.goto(`${BASE}?pref=en&turns=2&zoom=${zoom}`, { waitUntil: "networkidle" }); + await page.waitForSelector(".app"); + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(500); + const tag = `${scheme} ${width}px zoom ${zoom}`; + const m = await probe(page); + check(`${tag}: title bar controls do not overlap`, m.bar.overlaps.length === 0, m.bar.overlaps.join(", ")); + check(`${tag}: title bar stays inside the window`, m.bar.outside.length === 0, m.bar.outside.join(", ")); + check(`${tag}: composer row does not overlap`, m.row.overlaps.length === 0, m.row.overlaps.join(", ")); + check(`${tag}: composer toolbar controls stay in the toolbar`, m.toolsOverflow.length === 0, m.toolsOverflow.join(", ")); + check(`${tag}: the title keeps its 4em floor inside its own box`, m.crumbWidth >= 48 && !m.titleOutsideCrumb, `crumb ${Math.round(m.crumbWidth)}px`); + if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-${scheme}-${width}-z${zoom}.png` }); + const hint = await page.evaluate(() => { + const el = document.querySelector(".compose textarea"); + const cs = getComputedStyle(el); + const ctx = document.createElement("canvas").getContext("2d"); + ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + const room = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); + return { text: el.placeholder, width: ctx.measureText(el.placeholder).width, room }; + }); + check(`${tag}: the composer's placeholder is on one line and not empty`, hint.text.length > 0 && hint.width <= hint.room + 1, JSON.stringify(hint)); + const reach = await reachable(page); + check(`${tag}: every title-bar control can be reached, folded or not`, reach.length === 0, reach.join(", ")); + if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-open-${scheme}-${width}-z${zoom}.png` }); + await page.close(); + } +} +await browser.close(); +if (fails.length) { + console.log(`\n${fails.length} failed`); + process.exit(1); +} +console.log("\nall ok"); diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index 557e3b29e2..c69cdd46bd 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -56,6 +56,7 @@ export const ACTIONS: UIAction[] = [ { id: "chart.data", kind: "view", target: "none", proof: "interaction" }, { id: "chrome.policy", kind: "view", target: "none", proof: "interaction" }, { id: "chrome.rail", kind: "view", target: "none", proof: "browser" }, + { id: "chrome.more", kind: "view", target: "none", proof: "interaction" }, { id: "appearance.theme", kind: "view", target: "none", proof: "interaction" }, { id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" }, { id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" }, diff --git a/desktop/frontend-next/src/i18n/en_composer.ts b/desktop/frontend-next/src/i18n/en_composer.ts index 2a0677a56b..525c8f7415 100644 --- a/desktop/frontend-next/src/i18n/en_composer.ts +++ b/desktop/frontend-next/src/i18n/en_composer.ts @@ -18,6 +18,7 @@ export const EN_COMPOSER: Record = { "正在停止…": "Stopping…", "正在发送…": "Sending…", "描述任务、问题或要改的内容…": "Describe a task, question, or change…", + "描述任务…": "Describe a task…", "任务输入": "Task input", "命令与技能": "Commands and skills", "正在加载命令与技能…": "Loading commands and skills…", diff --git a/desktop/frontend-next/src/main.tsx b/desktop/frontend-next/src/main.tsx index e4b2deaa66..38c71b26bb 100644 --- a/desktop/frontend-next/src/main.tsx +++ b/desktop/frontend-next/src/main.tsx @@ -9,6 +9,7 @@ import "./styles/dock.css"; import "./styles/studio.css"; import "./styles/tips.css"; import "./styles/waitnote.css"; +import "./styles/narrow.css"; import "./styles/feedback.css"; import "./styles/community.css"; import "./styles/sponsor.css"; diff --git a/desktop/frontend-next/src/styles/LAYOUT.md b/desktop/frontend-next/src/styles/LAYOUT.md index 352688f0a8..f101b8bcb0 100644 --- a/desktop/frontend-next/src/styles/LAYOUT.md +++ b/desktop/frontend-next/src/styles/LAYOUT.md @@ -639,6 +639,13 @@ told apart by the colour of a symbol. - Under it the rail is a drawer, not a column (see *Phones*). No seam is left to drag and the chrome's rail button is the entrance, so the grip goes; so does the workbench grip, whose entrance is the 「工作台」 tab. +- The title bar folds the same way: it gathers its controls under one button, it does not drop them. + - Below 300px of room (`bar`, in `viewport.ts`) the phone, find, theme, sandbox and browser buttons move into one + popover under a "more" button, every one still a press away. + - 300 is derived, not chosen: unfolded, the controls leave the title `room - 196` px, and 4em is its floor. + - `perf/narrow.mjs` sweeps the room from 190 to 460 and asserts the title keeps that floor, and that each control is + on screen with nothing over it, folded or not. + - Below `bar` the page tabs shrink and ellipsise rather than clip, and the meter row drops its cache figure first. - When both side columns have yielded, the transcript rail yields too; its ticks and viewport remain separately reachable. - A short window divides its height proportionally, and what is left over is the diff --git a/desktop/frontend-next/src/styles/narrow.css b/desktop/frontend-next/src/styles/narrow.css new file mode 100644 index 0000000000..da349efa76 --- /dev/null +++ b/desktop/frontend-next/src/styles/narrow.css @@ -0,0 +1,9 @@ +.studio-model-group { flex-wrap: wrap; } +.chrome .r-fold { display: contents; } +[data-fold~="bar"] .chrome .r-fold { display: none; position: absolute; top: calc(100% + 6px); right: 12px; z-index: 80; flex-wrap: wrap; gap: 5px; padding: 6px; border: 1px solid var(--border); border-radius: 10px; background: var(--raised); box-shadow: 0 12px 32px -14px #000; } +[data-fold~="bar"] .chrome .r-fold[data-open] { display: flex; } +[data-fold~="bar"] .tab { flex: 0 1 auto; min-width: 0; padding-inline: 6px; overflow: hidden; text-overflow: ellipsis; } +@container conversation (max-width: 360px) { .studio-effort-picker::before { display: none; } } +@container conversation (max-width: 200px) { .studio-meterrail .studio-meter-cache { display: none; } } +.chrome .thbtn.r-more { color: var(--muted); } +.chrome .thbtn.r-more svg { stroke-width: 2.6; } diff --git a/desktop/frontend-next/src/styles/studio.css b/desktop/frontend-next/src/styles/studio.css index a8add8aab1..87fd34116e 100644 --- a/desktop/frontend-next/src/styles/studio.css +++ b/desktop/frontend-next/src/styles/studio.css @@ -502,7 +502,7 @@ body { background: var(--page) !important; } /* Prototype header */ .chrome .studio-menu { width: 30px; margin-left: -6px; } -.chrome .crumb { gap: 8px; color: var(--faint); font-size: 12px; } +.chrome .crumb { gap: 8px; color: var(--faint); font-size: 12px; overflow: hidden; } .chrome .crumb-folder { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.25; } .chrome .crumb-proj { color: var(--muted); font-weight: 600; } .chrome .crumbsep { color: #555852; } @@ -2215,7 +2215,7 @@ body { font-size: 14px; line-height: 1.75; } border-radius: 999px; background: transparent; box-shadow: none; - flex: 1 0 172px; + flex: 1 1 172px; justify-content: flex-end; } .studio-mode-control, diff --git a/desktop/frontend-next/src/ui/Appearance.tsx b/desktop/frontend-next/src/ui/Appearance.tsx index 21987168e4..002b969e14 100644 --- a/desktop/frontend-next/src/ui/Appearance.tsx +++ b/desktop/frontend-next/src/ui/Appearance.tsx @@ -152,7 +152,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight, const set = useCallback((patch: Partial) => onLook({ ...look, ...patch }), [look, onLook]); // Until the kernel has announced its range there is nothing to draw the // slider from; it is held inert rather than given a range of its own. - const zoomRange = look.zoomRange ?? { min: 0, max: 1, step: 1 }; + const zoomRange = look.zoomRange ?? { min: 0, max: 2, step: 1 }; const setPaper = useCallback( (patch: Partial>) => look.wallpaper && onLook({ ...look, wallpaper: { ...look.wallpaper, ...patch } }), diff --git a/desktop/frontend-next/src/ui/Chrome.tsx b/desktop/frontend-next/src/ui/Chrome.tsx index 03673ec934..f346ebd6d8 100644 --- a/desktop/frontend-next/src/ui/Chrome.tsx +++ b/desktop/frontend-next/src/ui/Chrome.tsx @@ -1,8 +1,10 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { t } from "../i18n"; import type { HubPort } from "../port/hub"; import type { AccountState, AgentPort, SessionStatus, WorkspaceInfo } from "../port/port"; import { DeviceBar } from "./DeviceBar"; +import { useDismiss } from "./dismiss"; +import { useFolded } from "./usefold"; import { ariaChord, chord } from "./keys"; import { PhonePop } from "./PhonePop"; import { WindowControls, zoomOnTitleBar } from "./WindowControls"; @@ -51,6 +53,21 @@ export function Chrome({ port, status, title, steer, onSettings, onBrowser, brow port.workspaces().then(setWs).catch(() => setWs(null)); }, [port, root]); + const gathered = useFolded("bar"); + const [more, setMore] = useState(false); + const group = useRef(null); + const shut = useCallback(() => setMore(false), []); + useDismiss(more && gathered, group, shut); + const trigger = useRef(null); + const wasOpen = useRef(false); + useEffect(() => { + // Escape leaves focus on a control that has just been hidden; give it back to the button that opened it. + const at = document.activeElement; + const lost = !at || at === document.body || !!document.getElementById("chrome-fold")?.contains(at); + if (wasOpen.current && !more && lost) trigger.current?.focus(); + wasOpen.current = more; + }, [more]); + return ( <> {hub && } @@ -80,7 +97,13 @@ export function Chrome({ port, status, title, steer, onSettings, onBrowser, brow {t("插话待送达")} {steer} -
+
+ {gathered && ( + + )} +
{hub && onError && } {port && ( +
diff --git a/desktop/frontend-next/src/ui/Composer.tsx b/desktop/frontend-next/src/ui/Composer.tsx index 5b8bf6c97b..100fa42783 100644 --- a/desktop/frontend-next/src/ui/Composer.tsx +++ b/desktop/frontend-next/src/ui/Composer.tsx @@ -10,6 +10,7 @@ import { effortMenu, effortReading, effortsFor, forcesThinkingFor, routeEffortPi import { CompletionMenu, useCompletion } from "./Completion"; import { ChipMirror, useSkillChips } from "./ChipMirror"; import { useIme } from "./ime"; +import { usePlaceholderFit } from "./placeholderfit"; import { useFitHeight } from "./fitHeight"; import { countLines, pasteIsLong, planTone, planVerb } from "./intake"; import { kindOf, nameOf, previewURL } from "./chipfile"; @@ -199,6 +200,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi pending.current = null; }, []); useFitHeight(box, text, placeCaret); + const placeholder = usePlaceholderFit(box, [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]); // Attachments ride into the turn as path references, exactly as they do from // the CLI — the host saved the bytes, the turn parser resolves the token. A @@ -494,7 +496,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi rows={1} value={text} data-chips={chips.list.length > 0 ? "" : undefined} - placeholder={t("描述任务、问题或要改的内容…")} + placeholder={placeholder} role="combobox" aria-label={t("任务输入")} aria-describedby={guide} diff --git a/desktop/frontend-next/src/ui/chrome-fold.interaction.test.tsx b/desktop/frontend-next/src/ui/chrome-fold.interaction.test.tsx new file mode 100644 index 0000000000..6fd0e638ac --- /dev/null +++ b/desktop/frontend-next/src/ui/chrome-fold.interaction.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { App } from "./App"; +import { MockHub } from "../port/mock_hub"; +import { boot, STORAGE } from "../i18n"; +import { refresh } from "./viewport"; + +// jsdom lays nothing out, so the width the fold reads is whatever is said here. +function room(width: number) { + vi.spyOn(document.body, "clientWidth", "get").mockReturnValue(width); + vi.spyOn(document.body, "clientHeight", "get").mockReturnValue(900); + refresh(); +} + +beforeEach(async () => { + await import("./Settings"); + sessionStorage.clear(); + localStorage.clear(); + localStorage.setItem(STORAGE, "zh"); + boot(); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + room(1440); +}); +afterEach(() => { + cleanup(); + sessionStorage.clear(); + localStorage.clear(); + delete document.documentElement.dataset.fold; + vi.restoreAllMocks(); +}); + +async function open() { + const hub = new MockHub(); + const build = hub.portFor.bind(hub); + const prepared = new WeakSet(); + hub.portFor = (rt) => { + const port = build(rt); + if (!prepared.has(port)) { + prepared.add(port); + port.providerSetup = async () => null; + port.welcomeSeen = async () => true; + } + return port; + }; + const view = render(); + await screen.findByRole("combobox", { name: "任务输入" }); + return { hub, view }; +} + + +const more = () => screen.queryByRole("button", { name: "更多" }); +const fold = () => document.querySelector(".chrome .r-fold")!; + +it("keeps every title-bar control in the bar while there is room, with no 'more' button", async () => { + await open(); + expect(more()).toBeNull(); + expect(document.querySelector(".chrome .theme-toggle")).not.toBeNull(); + expect(document.documentElement.dataset.fold ?? "").not.toContain("bar"); +}); + +it("gathers the controls under one button below the bar fold, keeps them in the document, and closes on Escape", async () => { + room(300); + await open(); + expect(document.documentElement.dataset.fold).toContain("bar"); + const button = more()!; + expect(button.getAttribute("aria-expanded")).toBe("false"); + for (const sel of [".find-action", ".theme-toggle", ".top-action", ".browser-action"]) { + expect(document.querySelector(`.chrome .r-fold ${sel}`), sel).not.toBeNull(); + } + await userEvent.click(button); + expect(button.getAttribute("aria-expanded")).toBe("true"); + expect(fold().hasAttribute("data-open")).toBe(true); + expect(button.getAttribute("aria-haspopup")).toBe("true"); + expect(document.getElementById(button.getAttribute("aria-controls")!)).toBe(fold()); + document.querySelector(".chrome .find-action")!.focus(); + await userEvent.keyboard("{Escape}"); + expect(fold().hasAttribute("data-open")).toBe(false); + expect(document.activeElement).toBe(button); +}); + +it("a control inside the fold still does its job", async () => { + room(300); + await open(); + const before = document.documentElement.dataset.theme; + await userEvent.click(more()!); + await userEvent.click(document.querySelector(".chrome .theme-toggle")!); + expect(document.documentElement.dataset.theme).not.toBe(before); +}); + +it("unfolds again when the room comes back", async () => { + room(300); + await open(); + expect(more()).not.toBeNull(); + act(() => room(900)); + expect(more()).toBeNull(); +}); diff --git a/desktop/frontend-next/src/ui/placeholderfit.interaction.test.tsx b/desktop/frontend-next/src/ui/placeholderfit.interaction.test.tsx new file mode 100644 index 0000000000..29b06fcf2b --- /dev/null +++ b/desktop/frontend-next/src/ui/placeholderfit.interaction.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { useRef } from "react"; +import "./testkit"; +import { usePlaceholderFit } from "./placeholderfit"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function Box({ width }: { width: number }) { + const box = useRef(null); + const placeholder = usePlaceholderFit(box, ["a long placeholder", "short", "…"]); + return