mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
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:
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" },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user