From ae46c30d3ea7b2072ed971e280ffb663817025ad Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Fri, 6 Nov 2026 19:27:53 -0800 Subject: [PATCH] feat(studio): on a phone the composer's tool row is add, the model pill and send, with the other controls one tap behind a 'more tools' button; stop is a round pause button, and the pinned control order no longer applies on a phone --- desktop/frontend-next/src/design/phone.css | Bin 3533 -> 6463 bytes .../frontend-next/src/design/phone.test.ts | 66 ++++++++++++++++ .../frontend-next/src/i18n/msg/composer.ts | 1 + desktop/frontend-next/src/styles/studio.css | 12 +-- .../ui/Composer.phone.interaction.test.tsx | 72 ++++++++++++++++++ desktop/frontend-next/src/ui/Composer.tsx | 50 +++++------- desktop/frontend-next/src/ui/StopButton.tsx | 28 +++++++ 7 files changed, 194 insertions(+), 35 deletions(-) create mode 100644 desktop/frontend-next/src/ui/Composer.phone.interaction.test.tsx create mode 100644 desktop/frontend-next/src/ui/StopButton.tsx diff --git a/desktop/frontend-next/src/design/phone.css b/desktop/frontend-next/src/design/phone.css index d6badae84016c5346a63f1cf3bb95864ed9f1e86..3944d54be2deb254b8ce308004eaca3c535a1d21 100644 GIT binary patch literal 6463 zcmcIo-EP}97~OTAf(TF`0YW!U(r%T}puirZ=mjlNHWx`0NGf(#WY50CKUuV;*iN^L z1{N#v`#vSMvUKDXNiX<1Q-X62s9AMMut zd$_az^?LDrbQf*9LP%Qkro&bhFHL5@S`Z_#YtE|t>gt)PD@wgRy1piq+~JjL@{!zK zFUUp|W}UgE1zB^kT4M|Q!h$TND!8JG6{6D_@%t#MYkZc=N~*Y4cZL^OJjF}>yn?5} z>fFU0t@JaLg=<) zFBxgj0;k6GNNij++x7{$z5TEtwP@nPEuuvI5>q;vYuE7)HmyhEN}8->qOoqK!79EQ#9E77$%9~z z6-v1bs}*wUCkQEhy@(U)n~U1GABn)LzR7$MLzgCP>>l=J=aVhklI2e;CA+4G#gb1h z?}w)Rv}?l*|9Yvq28iK}AMVj`n-$iGn)zOD*A!on`RqXx&NQcpOLm5crW6foG#*@H zv5=d$Om3KJoa5h~_)Do+%{2*Df0Ek|H)M_`MW$rSa$(T%vwJ(l1X&oaVoqO?=x;SI z1S40i;w4v_DqeIsFK8`&JpTC_UmoTuWMvS27%uFaDJhK)iOp#f!goj4G+~8-js$0>($$m869`pWN@1f234}2Z5kab4-F^? ztTH*VlgmnOFq*8Ye9~FlD*4PRh#b3FnQ-4qi2}rmzZ7f%K1A^l4#*kIF)x+<7l_ws zkg(g1C=tV9t^`gLH( z5PXbc4JJ`RQ|&24t`umn~nZL2_bYUnAbBzkI{J|J3_TU7#glhN_3v|Kqd z`xU>kMw+YCxf|l23!78RUVUeHAPG6X3)A3-0Evb!r>}x zv_0jt64KM25?&z%Os5(nWu=MxISH0F_cM)i7@3Kjwye`r?#AGXj` zo%*kLzIw)_8)Z+M_V0vf-3uW_ljA;#3b#?R{J)pa{ z4;@E~df-)-EqUqXV1H+F`P=gM%V~xhF#<@iP!zFpM}2z{qnoWf92HF=7pLMwuU-24 ztN$Gdb|D_Ue)$nT1euqGc_H7TNJMN!G$=J`y-O;^!N^ke36we6H_2cx$9Gw-cMfm}`_?v_Gi|NT69oUf1 zT;Z~GU$Gy%s*JKJ)Bx8*IFEP<3>7xg$){$+!atG`iZ*wA76I8mjScM4-4l)I#NvPj zPQ>zLj{}_ { const at = phone.indexOf(selector + " {"); @@ -90,3 +91,68 @@ describe("the approval card on a phone", () => { expect(rule(footer + " .apv-more")).toMatch(/color:\s*var\(--link\)/); }); }); + +describe("the composer's tool row on a phone", () => { + const row = '[data-fold~="phone"] .compose .turntools'; + + it("sizes add as a 32px round button with a hairline", () => { + const add = rule(row + " > .studio-attach:first-child"); + expect(add).toMatch(/width:\s*32px/); + expect(add).toMatch(/height:\s*32px/); + expect(add).toMatch(/border-radius:\s*50%/); + expect(add).toMatch(/border:\s*1px solid var\(--border\)/); + }); + + it("draws the model as a 28px pill in the muted interface font", () => { + const pill = rule(row + " .model-picker"); + expect(pill).toMatch(/height:\s*28px/); + expect(pill).toMatch(/border-radius:\s*999px/); + expect(pill).toMatch(/padding:\s*0 10px/); + expect(pill).toMatch(/color:\s*var\(--muted\)/); + expect(pill).toMatch(/font:\s*var\(--w-reg\) var\(--fs-meta\)/); + }); + + it("keeps everything else in the row behind the 'more' button until it is opened", () => { + expect(rule(row + " > :not(:first-child, .studio-model-group, .turn-more)")).toMatch(/display:\s*none/); + expect(rule(row + "[data-more] > :not(:first-child, .studio-model-group, .turn-more)")).toMatch(/display:\s*(flex|inline-flex)/); + expect(rule(".turntools .turn-more")).toMatch(/display:\s*none/); + expect(rule(row + " > .turn-more")).toMatch(/display:\s*inline-grid/); + }); + + it("makes send a 36px accent circle, and stop the same size with the pause mark", () => { + const both = rule('[data-fold~="phone"] .compose .go .btn.send, [data-fold~="phone"] .compose .go .btn.stop'); + expect(both).toMatch(/width:\s*36px/); + expect(both).toMatch(/height:\s*36px/); + expect(both).toMatch(/border-radius:\s*50%/); + expect(rule('\n[data-fold~="phone"] .compose .go .btn.send')).toMatch(/background:\s*var\(--accent\)/); + expect(rule('\n[data-fold~="phone"] .compose .go .btn.stop')).toMatch(/background:\s*var\(--overlay\)/); + expect(rule('[data-fold~="phone"] .compose .btn.stop .ic-stop')).toMatch(/display:\s*none/); + expect(rule(".btn.stop .ic-pause")).toMatch(/display:\s*none/); + }); + + it("drops the keyboard hints, which a touch screen has no keys for", () => { + expect(rule('[data-fold~="phone"] .compose .composecaps')).toMatch(/display:\s*none/); + }); +}); + +// An older rule pins the add button's order with !important, so it is scoped +// away from a phone and the phone's own order wins by source order. +describe("the order of the phone's tool row", () => { + const row = '[data-fold~="phone"] .compose .turntools'; + + it("runs add, model, the more button, then a line break, then the revealed controls", () => { + expect(rule(row + " > :first-child")).toMatch(/order:\s*0;/); + expect(rule(row + " > .studio-model-group")).toMatch(/order:\s*1;/); + expect(rule(row + " > .turn-more")).toMatch(/order:\s*2;/); + expect(rule(row + "[data-more]::after")).toMatch(/flex:\s*0 0 100%/); + expect(rule(row + "[data-more]::after")).toMatch(/order:\s*3;/); + expect(rule(row + " > :not(:first-child, .studio-model-group, .turn-more)")).toMatch(/order:\s*4;/); + }); + + it("is not fought by an !important order on a phone", () => { + expect(phone).not.toMatch(/!important/); + const pinned = studio.split("\n").filter((l) => /\.turntools > [^{]*\{[^}]*order:[^;]*!important/.test(l)); + expect(pinned.length).toBeGreaterThanOrEqual(6); + for (const line of pinned) expect(line).toMatch(/^:root:not\(\[data-fold~="phone"\]\) /); + }); +}); diff --git a/desktop/frontend-next/src/i18n/msg/composer.ts b/desktop/frontend-next/src/i18n/msg/composer.ts index cb01b2af12..c2e2ce4829 100644 --- a/desktop/frontend-next/src/i18n/msg/composer.ts +++ b/desktop/frontend-next/src/i18n/msg/composer.ts @@ -263,6 +263,7 @@ export const COMPOSER: Messages = { "composer.steer_queued": { zh: "插话排队", en: "Steer queued" }, "composer.steer_waiting": { zh: "插话 · 等待插入", en: "Steer · waiting to land" }, "composer.stop": { zh: "停下", en: "Stop" }, + "composer.more_tools": { zh: "更多工具", en: "More tools" }, "composer.stop_the_current_response_and": { zh: "停止当前回复,把这条作为新一轮立即发出", en: "Stop the current response and send this as a new turn" }, "composer.stopping": { zh: "正在停止…", en: "Stopping…" }, "composer.switches_immediately_other_sessions_in": { zh: "选择后立即切换,同目录会话也会受影响", en: "Switches immediately; other sessions in this folder are affected" }, diff --git a/desktop/frontend-next/src/styles/studio.css b/desktop/frontend-next/src/styles/studio.css index a90c504ce3..98075526c1 100644 --- a/desktop/frontend-next/src/styles/studio.css +++ b/desktop/frontend-next/src/styles/studio.css @@ -1802,12 +1802,12 @@ button, input, textarea { font-size: var(--fs-ui); } .session-pop-group > button.danger { color: var(--err-ink); } .session-pop-group > button.danger > .studio-icon { color: var(--err-ink); } -.turntools > .attach { order: 1 !important; } -.turntools > .studio-mode-control { order: 2 !important; } -.turntools > .studio-branch-pop { order: 3 !important; } -.turntools > [data-plan-phase] { order: 4 !important; } -.turntools > .policy { order: 4 !important; } -.turntools > .studio-model-group { order: 6 !important; } +:root:not([data-fold~="phone"]) .turntools > .attach { order: 1 !important; } +:root:not([data-fold~="phone"]) .turntools > .studio-mode-control { order: 2 !important; } +:root:not([data-fold~="phone"]) .turntools > .studio-branch-pop { order: 3 !important; } +:root:not([data-fold~="phone"]) .turntools > [data-plan-phase] { order: 4 !important; } +:root:not([data-fold~="phone"]) .turntools > .policy { order: 4 !important; } +:root:not([data-fold~="phone"]) .turntools > .studio-model-group { order: 6 !important; } .turntools > .picker:first-of-type { order: unset; } .studio-model-group { min-width: 0; diff --git a/desktop/frontend-next/src/ui/Composer.phone.interaction.test.tsx b/desktop/frontend-next/src/ui/Composer.phone.interaction.test.tsx new file mode 100644 index 0000000000..1768cf2150 --- /dev/null +++ b/desktop/frontend-next/src/ui/Composer.phone.interaction.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { Composer } from "./Composer"; +import { MockPort } from "../port/mock"; +import { boot, STORAGE } from "../i18n"; + +const PHONE = "rail side scene phone"; + +beforeEach(() => { + localStorage.clear(); + localStorage.setItem(STORAGE, "zh"); + boot(); +}); +afterEach(() => { + cleanup(); + localStorage.clear(); + delete document.documentElement.dataset.fold; + vi.restoreAllMocks(); +}); + +function draw(running = false) { + return render( + true)} onChanged={vi.fn()} onError={vi.fn()} />, + ).container; +} + +const tools = (box: HTMLElement) => box.querySelector(".turntools")!; + +// A phone's tool row is add, model, send; the rest of the row's controls are +// one tap behind a single button rather than gone. +describe("the composer's tool row on a phone", () => { + it("offers one button for the controls that are not add, model or send", async () => { + document.documentElement.dataset.fold = PHONE; + const box = draw(); + const more = screen.getByRole("button", { name: "更多工具" }); + expect(more.getAttribute("aria-expanded")).toBe("false"); + expect(tools(box).hasAttribute("data-more")).toBe(false); + await userEvent.click(more); + expect(more.getAttribute("aria-expanded")).toBe("true"); + expect(tools(box).hasAttribute("data-more")).toBe(true); + await userEvent.click(more); + expect(tools(box).hasAttribute("data-more")).toBe(false); + }); + + it("puts that button in the tool row, after the model", () => { + document.documentElement.dataset.fold = PHONE; + const box = draw(); + const kids = [...tools(box).children]; + const model = kids.findIndex((el) => el.classList.contains("studio-model-group")); + const more = kids.findIndex((el) => el.classList.contains("turn-more")); + expect(model).toBeGreaterThanOrEqual(0); + expect(more).toBeGreaterThan(model); + }); + + it("leaves a wider screen's row exactly as it was", () => { + const box = draw(); + expect(screen.queryByRole("button", { name: "更多工具" })).toBeNull(); + expect(tools(box).querySelector(".turn-more")).toBeNull(); + }); +}); + +describe("the stop button", () => { + it("carries the board's pause mark as well as the square, and the stylesheet picks one", () => { + const box = draw(true); + const stop = box.querySelector(".btn.stop")!; + expect(stop.querySelector(".ic-stop")).not.toBeNull(); + expect(stop.querySelector(".ic-pause")).not.toBeNull(); + }); +}); diff --git a/desktop/frontend-next/src/ui/Composer.tsx b/desktop/frontend-next/src/ui/Composer.tsx index d26a73c726..f5e1acc4d7 100644 --- a/desktop/frontend-next/src/ui/Composer.tsx +++ b/desktop/frontend-next/src/ui/Composer.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; +import { useFolded } from "./usefold"; +import { StopButton } from "./StopButton"; import { createPortal } from "react-dom"; import { t } from "../i18n"; import { reason } from "../i18n/kernel"; @@ -104,6 +106,8 @@ export function Composer({ port, status, running, queued = false, quote, restore uploads.current.clear(); }, []); const [stopping, setStopping] = useState(false); + const phone = useFolded("phone"); + const [toolsOpen, setToolsOpen] = useState(false); const submittingRef = useRef(false); const stoppingRef = useRef(false); const picker = useRef(null); @@ -419,6 +423,16 @@ export function Composer({ port, status, running, queued = false, quote, restore const warnPictures = status?.vision === false && shots.some((c) => c.k === "attachment" && c.state === "ready" && isPicture(c)); const setupPage = worktree.stage && void worktree.cancel()} onEdit={worktree.dismiss}/>; + const stop = () => { + if (stoppingRef.current) return; + stoppingRef.current = true; + setStopping(true); + void port.cancel().then(onChanged, (e: unknown) => { + stoppingRef.current = false; + setStopping(false); + onError(e); + }); + }; return (<> {setupPage && (worktreeTarget ? createPortal(setupPage, worktreeTarget) : setupPage)}
@@ -663,7 +677,7 @@ export function Composer({ port, status, running, queued = false, quote, restore e.target.value = ""; }} /> -
+