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

This commit is contained in:
yhh committed 2026-11-06 19:27:53 -08:00
1 parent 411e19d87e
commit ae46c30d3e
7 files changed
+194 -35

No files matched your search

Binary file not shown.
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import phone from "./phone.css?raw";
import index from "./design.css?raw";
import narrow from "../styles/narrow.css?raw";
import studio from "../styles/studio.css?raw";
const rule = (selector: string): string => {
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"\]\) /);
});
});
@@ -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" },
+6 -6
View File
@@ -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;
@@ -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(
<Composer port={new MockPort()} status={null} running={running} queued={false} onSubmit={vi.fn(async () => true)} onChanged={vi.fn()} onError={vi.fn()} />,
).container;
}
const tools = (box: HTMLElement) => box.querySelector<HTMLElement>(".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();
});
});
+21 -29
View File
@@ -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<HTMLInputElement>(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 && <WorktreeSetupPage stage={worktree.stage} task={setupDraft.text} files={setupDraft.files} busy={submitting} onRetry={send} onCancel={() => 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)}
<div className="dropzone" ref={dropzone} data-worktree-setup={worktree.stage ? "" : undefined} inert={!!worktree.stage}>
@@ -663,7 +677,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
e.target.value = "";
}}
/>
<div className="turntools">
<div className="turntools" data-more={phone && toolsOpen ? "" : undefined}>
<button
className="mode plain attach studio-attach"
aria-label={t("composer.add_an_attachment")}
@@ -745,36 +759,14 @@ export function Composer({ port, status, running, queued = false, quote, restore
label={<>{hasVendorMark(currentMark) && <VendorMark slug={currentMark} size={14} />}<span className="nm">{modelLb}</span>{status && <><i className="studio-model-sep" aria-hidden="true" /><span className="studio-effort-reading"><StudioIcon name="brain" />{declared ? effortShort(status.effort, status.modes) : t("common.not_declared")}</span></>}<StudioIcon name="down" /></>}
/>
</div>
{phone && (
<button type="button" className="mode plain turn-more" data-action="composer.more-tools" aria-expanded={toolsOpen} aria-label={t("composer.more_tools")} onClick={() => setToolsOpen((on) => !on)}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8h.01M8 8h.01M12.5 8h.01" /></svg>
</button>
)}
</div>
<span className="go">
{running && (
<button
className="btn stop"
data-primary
data-action="session.stop"
data-pending={stopping ? "" : undefined}
aria-label={t(stopping ? "composer.stopping" : "composer.stop")}
disabled={stopping}
aria-busy={stopping}
onClick={() => {
if (stoppingRef.current) return;
stoppingRef.current = true;
setStopping(true);
void port.cancel().then(onChanged, (e: unknown) => {
stoppingRef.current = false;
setStopping(false);
onError(e);
});
}}
>
<span className="ic" aria-hidden="true">
<svg viewBox="0 0 16 16">
<rect x="4.8" y="4.8" width="6.4" height="6.4" rx="1.3" />
</svg>
</span>
<span>{t(stopping ? "composer.stopping" : "composer.stop")}</span>
</button>
)}
{running && <StopButton stopping={stopping} onStop={stop} />}
<button
className="btn send"
aria-label={t(submitting ? "common.sending" : running ? runningIntent === "followup" ? "composer.queue" : "common.steer" : "common.send")}
@@ -0,0 +1,28 @@
import { t } from "../i18n";
/** The button that ends a running turn. It carries both the square and the
* pause mark; the stylesheet shows the pause one where the stop is a round
* button on a phone. */
export function StopButton({ stopping, onStop }: { stopping: boolean; onStop: () => void }) {
const label = t(stopping ? "composer.stopping" : "composer.stop");
return (
<button
className="btn stop"
data-primary
data-action="session.stop"
data-pending={stopping ? "" : undefined}
aria-label={label}
disabled={stopping}
aria-busy={stopping}
onClick={onStop}
>
<span className="ic" aria-hidden="true">
<svg viewBox="0 0 16 16">
<rect className="ic-stop" x="4.8" y="4.8" width="6.4" height="6.4" rx="1.3" />
<path className="ic-pause" d="M5.5 3.5v9M10.5 3.5v9" />
</svg>
</span>
<span>{label}</span>
</button>
);
}