feat(studio): on a phone the title bar keeps the session list, the title with the machine under it and one menu, a lost connection says it is reconnecting, and an approval is two 44px buttons with the other choices behind 'more options'

This commit is contained in:
yhh committed 2026-11-06 19:14:06 -08:00
1 parent c155364b3d
commit 411e19d87e
16 files changed
+360 -10

No files matched your search

@@ -9,6 +9,7 @@
@import "./composer.css" layer(design);
@import "./turn.css" layer(design);
@import "./plan.css" layer(design);
@import "./phone.css" layer(design);
@import "./subagent.css" layer(design);
@import "./jobs.css" layer(design);
@import "./workbench.css" layer(design);
@@ -0,0 +1,30 @@
[data-fold~="phone"] .chrome { gap: 6px; padding: 6px 8px 8px 42px; }
[data-fold~="phone"] .studio-menu { left: 8px; top: calc((var(--chrome-h) - 28px) / 2); width: 28px; height: 28px; border-radius: var(--r-sm); color: var(--muted); }
[data-fold~="phone"] .studio-menu svg { width: 16px; height: 16px; stroke-width: 1.45; }
[data-fold~="phone"] .chrome .crumb { flex: 1; flex-direction: column; justify-content: center; align-self: stretch; gap: 1px; text-align: center; }
[data-fold~="phone"] .chrome .crumb-context { display: none; }
[data-fold~="phone"] .chrome .crumb > b { max-width: 100%; min-width: 0; font: var(--w-semi) var(--fs-lead)/1.2 var(--ui); color: var(--text); }
[data-fold~="phone"] .chrome .crumb-sub { display: flex; align-items: center; justify-content: center; gap: 4px; max-width: 100%; min-width: 0; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.2 var(--ui); }
[data-fold~="phone"] .chrome .r { margin-left: 0; }
.chrome .crumb-sub > i { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--ok); }
.chrome .crumb-sub[data-state="run"] > i { background: var(--net); animation: ph-run 1.4s ease-in-out infinite; }
.chrome .crumb-sub[data-state="down"] > i { background: var(--warn); }
@keyframes ph-run { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .chrome .crumb-sub > i { animation: none; } }
:root[data-effects="reduced"] .chrome .crumb-sub > i { animation: none; }
.devicebar .db-retry, .devicebar .db-spin { display: none; }
[data-fold~="phone"] .devicebar[data-down] { height: auto; min-height: 30px; padding: 10px 12px; background: color-mix(in srgb, var(--warn) 14%, var(--surface)); font: var(--w-reg) var(--fs-ui)/1.3 var(--ui); }
[data-fold~="phone"] .devicebar[data-down] > i { background: var(--warn); }
[data-fold~="phone"] .devicebar[data-down] .db-what { flex: 1; white-space: normal; }
[data-fold~="phone"] .devicebar[data-down] .db-spin { display: inline-block; flex: none; width: 13px; height: 13px; color: var(--net); }
[data-fold~="phone"] .devicebar[data-down] .db-retry { display: inline; flex: none; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1 var(--ui); font-style: normal; }
.apv-more { display: none; }
[data-fold~="phone"] .flow .call[data-approval] .apv-ft { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 10px 12px; }
[data-fold~="phone"] .flow .call[data-approval] .apv-ft .btn { box-sizing: border-box; width: 100%; justify-self: stretch; height: 44px; min-height: 44px; display: grid; place-items: center; padding: 0 10px; border-radius: 10px; font: var(--w-med) var(--fs-lead)/1 var(--ui); }
[data-fold~="phone"] .flow .call[data-approval] .apv-ft .btn[data-deny] { order: 0; border: 1px solid var(--border); background: transparent; color: var(--err-ink); }
[data-fold~="phone"] .flow .call[data-approval] .apv-ft .btn[data-primary] { order: 1; border: 0; background: var(--accent); color: var(--accent-fg); }
[data-fold~="phone"] .flow .call[data-approval] .apv-ft .btn:not([data-primary], [data-deny]) { display: none; order: 2; grid-column: 1 / -1; }
[data-fold~="phone"] .flow .call[data-approval] .apv-ft[data-more] .btn:not([data-primary], [data-deny]) { display: grid; }
[data-fold~="phone"] .flow .call[data-approval] .apv-ft .apv-more { display: block; order: 3; grid-column: 1 / -1; justify-self: start; min-height: 32px; padding: 0; border: 0; background: none; color: var(--link); font: var(--w-reg) var(--fs-meta)/1 var(--ui); cursor: pointer; }
@@ -0,0 +1,92 @@
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";
const rule = (selector: string): string => {
const at = phone.indexOf(selector + " {");
return at < 0 ? "" : phone.slice(at, phone.indexOf("}", at));
};
describe("the phone's title bar", () => {
it("is part of the design layer", () => {
expect(index).toMatch(/@import "\.\/phone\.css" layer\(design\)/);
});
it("puts the session list at the left edge at the board's 28px, and leaves room for it", () => {
const menu = rule('[data-fold~="phone"] .studio-menu');
expect(menu).toMatch(/left:\s*8px/);
expect(menu).toMatch(/width:\s*28px/);
expect(menu).toMatch(/height:\s*28px/);
expect(rule('[data-fold~="phone"] .chrome')).toMatch(/padding:\s*6px 8px 8px 42px/);
});
it("stacks a centred title over the machine line", () => {
expect(rule('[data-fold~="phone"] .chrome .crumb')).toMatch(/flex-direction:\s*column/);
expect(rule('[data-fold~="phone"] .chrome .crumb')).toMatch(/text-align:\s*center/);
expect(rule('[data-fold~="phone"] .chrome .crumb > b')).toMatch(/font:\s*var\(--w-semi\) var\(--fs-lead\)/);
expect(rule('[data-fold~="phone"] .chrome .crumb-sub')).toMatch(/color:\s*var\(--faint\)/);
});
it("colours the machine's dot by state, and only a running turn breathes", () => {
expect(rule(".chrome .crumb-sub > i")).toMatch(/background:\s*var\(--ok\)/);
expect(rule('.chrome .crumb-sub[data-state="run"] > i')).toMatch(/background:\s*var\(--net\)/);
expect(rule('.chrome .crumb-sub[data-state="run"] > i')).toMatch(/animation:\s*ph-run 1\.4s/);
expect(rule('.chrome .crumb-sub[data-state="down"] > i')).toMatch(/background:\s*var\(--warn\)/);
expect(rule('.chrome .crumb-sub[data-state="down"] > i')).not.toMatch(/animation/);
expect(phone).toMatch(/@keyframes ph-run\s*\{\s*0%, 100%\s*\{\s*opacity:\s*\.35/);
});
it("stops breathing under reduced motion, the system's or the app's", () => {
expect(phone).toMatch(/prefers-reduced-motion: reduce\)[^}]*\.crumb-sub[^{]*\{[^}]*animation:\s*none/);
expect(phone).toMatch(/:root\[data-effects="reduced"\] \.chrome \.crumb-sub[^{]*\{[^}]*animation:\s*none/);
});
});
describe("the lost-connection banner on a phone", () => {
it("turns warm and says it is reconnecting", () => {
const down = rule('[data-fold~="phone"] .devicebar[data-down]');
expect(down).toMatch(/color-mix\(in srgb, var\(--warn\) 14%, var\(--surface\)\)/);
expect(rule('[data-fold~="phone"] .devicebar[data-down] .db-retry')).toMatch(/display:\s*inline/);
});
it("keeps the note and the spinner out of the connected bar", () => {
expect(rule(".devicebar .db-retry, .devicebar .db-spin")).toMatch(/display:\s*none/);
});
});
describe("the menu the bar's controls gather into", () => {
it("collapses on a phone the same way it does in a very narrow window", () => {
const collapse = narrow.split("\n").find((l) => l.includes(".chrome .r-fold {") && l.includes("position: absolute")) ?? "";
expect(collapse).toMatch(/data-fold~="phone"/);
const opened = narrow.split("\n").find((l) => l.includes(".r-fold[data-open]")) ?? "";
expect(opened).toMatch(/data-fold~="phone"/);
});
});
describe("the approval card on a phone", () => {
const footer = '[data-fold~="phone"] .flow .call[data-approval] .apv-ft';
it("is two equal columns of finger-sized buttons", () => {
expect(rule(footer)).toMatch(/grid-template-columns:\s*1fr 1fr/);
expect(rule(footer)).toMatch(/gap:\s*8px/);
const button = rule(footer + " .btn");
expect(button).toMatch(/height:\s*44px/);
expect(button).toMatch(/border-radius:\s*10px/);
});
it("puts refuse on the left in the error colour and allow on the right in the accent", () => {
expect(rule(footer + " .btn[data-deny]")).toMatch(/order:\s*0/);
expect(rule(footer + " .btn[data-deny]")).toMatch(/color:\s*var\(--err-ink\)/);
expect(rule(footer + " .btn[data-primary]")).toMatch(/order:\s*1/);
expect(rule(footer + " .btn[data-primary]")).toMatch(/background:\s*var\(--accent\)/);
expect(rule(footer + " .btn[data-primary]")).toMatch(/color:\s*var\(--accent-fg\)/);
});
it("keeps the other choices behind 'more options' until they are asked for", () => {
expect(rule(footer + " .btn:not([data-primary], [data-deny])")).toMatch(/display:\s*none/);
expect(rule(footer + "[data-more] .btn:not([data-primary], [data-deny])")).toMatch(/display:\s*grid/);
expect(rule(".apv-more")).toMatch(/display:\s*none/);
expect(rule(footer + " .apv-more")).toMatch(/color:\s*var\(--link\)/);
});
});
@@ -106,6 +106,7 @@ export const APP: Messages = {
"app.send_no_thinking_parameter": { zh: "不发思考参数", en: "Send no thinking parameter" },
"app.session_panes": { zh: "会话面板", en: "Session panes" },
"app.show_the_workspace_rail": { zh: "展开工作区栏", en: "Show the workspace rail" },
"app.session_list": { zh: "会话列表", en: "Sessions" },
"app.sign_in": { zh: "重新登录", en: "Sign in" },
"app.sign_in_again": { zh: "需要重新登录", en: "Sign in again" },
"app.small": { zh: "小", en: "Small" },
@@ -19,6 +19,7 @@ export const CHAT: Messages = {
"chat.all_places": { zh: "全部地点", en: "All places" },
"chat.allow_for_this_session_lasts": { zh: "“本会话允许”只对这个对话有效,随对话保存,恢复这个对话后仍然有效。", en: "“Allow for this session” lasts for this conversation only and is saved with it, so it still holds when you resume the conversation." },
"chat.allow_once": { zh: "允许这一次", en: "Allow once" },
"chat.more_options": { zh: "更多选项…", en: "More options…" },
"chat.allow_this_kind_for_this": { zh: "本会话允许此类操作,不再询问", en: "Allow this kind for this session, stop asking" },
"chat.allowed": { zh: "已允许", en: "Allowed" },
"chat.allowed_once": { zh: "允许这一次。", en: "Allowed once." },
@@ -48,6 +48,8 @@ export const REMOTE: Messages = {
"remote.device_n_disconnected": { zh: "设备 {n} 已断开", en: "Device {n} disconnected" },
"remote.disconnect": { zh: "断开", en: "Disconnect" },
"remote.disconnect_2": { zh: "确认断开", en: "Disconnect?" },
"remote.running_on_machine": { zh: "执行中 · {machine}", en: "Running · {machine}" },
"remote.machine_disconnected": { zh: "{machine} · 已断开", en: "{machine} · Disconnected" },
"remote.disconnect_this_phone_it_will": { zh: "断开这台手机?它需要重新扫码才能再连上。", en: "Disconnect this phone? It will need to scan a new code to connect again." },
"remote.disconnected": { zh: "已断开", en: "Disconnected" },
"remote.distribution": { zh: "发行版", en: "Distribution" },
+21
View File
@@ -13,6 +13,27 @@ export function setViewer(next: DeviceSelf | null) {
subs.forEach((fn) => fn());
}
// Whether the machine this device drives still answers. The device bar is the
// one place that asks; the title bar reads the answer from here.
let reachable = true;
const reachSubs = new Set<() => void>();
export function setReachable(next: boolean) {
if (next === reachable) return;
reachable = next;
reachSubs.forEach((fn) => fn());
}
export function useReachable(): boolean {
return useSyncExternalStore(
(fn) => {
reachSubs.add(fn);
return () => reachSubs.delete(fn);
},
() => reachable,
);
}
export function useViewer(): DeviceSelf | null {
return useSyncExternalStore(
(fn) => {
+2 -2
View File
@@ -8,8 +8,8 @@
.chrome .r-fold { display: contents; }
.chrome .crumb-context { display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; }
.chrome .crumb-context .isolab { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
[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: var(--r-md); background: var(--raised); box-shadow: 0 12px 32px -14px var(--shade); }
[data-fold~="bar"] .chrome .r-fold[data-open] { display: flex; }
:is([data-fold~="bar"], [data-fold~="phone"]) .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: var(--r-md); background: var(--raised); box-shadow: 0 12px 32px -14px var(--shade); }
:is([data-fold~="bar"], [data-fold~="phone"]) .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: 280px) {
.turntools { flex-wrap: wrap; }
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import "./testkit";
import { Chrome } from "./Chrome";
import { RailToggle } from "./RailToggle";
import { boot, STORAGE } from "../i18n";
import { setReachable, setViewer } from "../state/viewer";
import type { SessionStatus } from "../port/session";
const PHONE = "rail side scene phone";
function atFold(fold: string) {
document.documentElement.dataset.fold = fold;
}
const status = (over: Partial<SessionStatus> = {}) =>
({ workspaceRoot: "D:/work/DeepSeek-Reasonix", running: false, ...over }) as SessionStatus;
function bar(over: Partial<SessionStatus> = {}) {
return render(
<Chrome
port={null} status={status(over)} workspace={null} title="并行会话演示" steer={0} account={null}
theme="light" onTheme={() => {}} onFind={() => {}} view={"flow" as never} rows={0} surfaces={0} onView={() => {}}
/>,
);
}
beforeEach(() => {
localStorage.setItem(STORAGE, "zh");
boot();
setViewer({ id: "d1", ordinal: 1, machine: "MacBook" });
setReachable(true);
});
afterEach(() => {
cleanup();
setViewer(null);
setReachable(true);
delete document.documentElement.dataset.fold;
vi.restoreAllMocks();
});
const sub = () => document.querySelector<HTMLElement>(".chrome .crumb-sub");
describe("the title bar on a phone", () => {
it("names the machine and the project under the title", () => {
atFold(PHONE);
bar();
expect(sub()?.textContent).toBe("MacBook · DeepSeek-Reasonix");
expect(sub()?.getAttribute("data-state")).toBe("ok");
});
it("says a turn is running, from the machine's side", () => {
atFold(PHONE);
bar({ running: true });
expect(sub()?.textContent).toBe("执行中 · MacBook");
expect(sub()?.getAttribute("data-state")).toBe("run");
});
it("says the machine has gone away, and which one", async () => {
atFold(PHONE);
bar();
await act(async () => setReachable(false));
expect(sub()?.textContent).toBe("MacBook · 已断开");
expect(sub()?.getAttribute("data-state")).toBe("down");
});
it("gathers find, theme and the rest behind one 'more' button", () => {
atFold(PHONE);
bar();
expect(screen.getByRole("button", { name: "更多" })).toBeTruthy();
});
it("draws nothing under the title off a paired device", () => {
atFold(PHONE);
setViewer(null);
bar();
expect(sub()).toBeNull();
});
it("leaves a window wider than a phone as it was", () => {
atFold("rail side scene");
bar();
expect(sub()).toBeNull();
expect(screen.queryByRole("button", { name: "更多" })).toBeNull();
});
});
describe("the button that opens the session list", () => {
it("is called the session list on a phone, and the workspace rail elsewhere", () => {
atFold(PHONE);
const view = render(<RailToggle rail={false} onRail={() => {}} />);
expect(screen.getByRole("button", { name: "会话列表" })).toBeTruthy();
view.unmount();
atFold("rail side scene");
render(<RailToggle rail={false} onRail={() => {}} />);
expect(screen.queryByRole("button", { name: "会话列表" })).toBeNull();
});
});
+15 -1
View File
@@ -13,6 +13,7 @@ import { TooltipButton } from "./TooltipButton";
import { workspaceDirectoryDescription } from "./workspaceDirectory";
import { WindowControls, zoomOnTitleBar } from "./WindowControls";
import type { PaneView } from "../state/paneview";
import { useReachable, useViewer } from "../state/viewer";
const base = (p: string) => p.replace(/[/\\]+$/, "").split(/[/\\]/).pop() || p;
// The filename is a timestamp and a model ref — true, and useless to read. The
@@ -58,7 +59,10 @@ export function Chrome({ port, status, workspace: ws, title, steer, account, hos
if (hub && root) void hub.openWorkspace(root).catch((e) => onError?.(e));
};
const gathered = useFolded("bar");
const phone = useFolded("phone");
const gathered = useFolded("bar") || phone;
const device = useViewer();
const reachable = useReachable();
const [more, setMore] = useState(false);
const group = useRef<HTMLDivElement>(null);
const shut = useCallback(() => setMore(false), []);
@@ -103,6 +107,16 @@ export function Chrome({ port, status, workspace: ws, title, steer, account, hos
<span className="crumbsep">/</span>
</span>
<b title={status?.sessionPath}>{sessionName(title, status?.sessionPath)}</b>
{phone && device && (
<span className="crumb-sub" data-state={!reachable ? "down" : status?.running ? "run" : "ok"}>
<i aria-hidden="true" />
{!reachable
? t("remote.machine_disconnected", { machine: device.machine })
: status?.running
? t("remote.running_on_machine", { machine: device.machine })
: `${device.machine} · ${project}`}
</span>
)}
</div>
<span className="badge" hidden={steer === 0}>
+9 -2
View File
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import type { HubPort } from "../port/hub";
import type { DeviceSelf } from "../port/share";
import { setViewer } from "../state/viewer";
import { setReachable as publishReachable, setViewer } from "../state/viewer";
import { RMark } from "./RMark";
// How often a device confirms the machine still answers. The event stream
// carries the work; this only keeps the bar's dot honest when it stops.
@@ -28,10 +29,14 @@ export function DeviceBar({ hub }: { hub: HubPort }) {
setSelf(d);
setViewer(d);
setReachable(true);
publishReachable(true);
return d !== null;
},
() => {
if (alive) setReachable(false);
if (alive) {
setReachable(false);
publishReachable(false);
}
return true;
},
);
@@ -67,6 +72,8 @@ export function DeviceBar({ hub }: { hub: HubPort }) {
{reachable ? t("remote.controlling_machine", { machine: self.machine }) : t("remote.lost_the_connection_to_machine", { machine: self.machine })}
</span>
<small>{t("remote.this_is_device_n", { n: self.ordinal })}</small>
{!reachable && <RMark className="plan-spin db-spin" />}
<em className="db-retry">{t("remote.reconnecting")}</em>
<button data-action={arming ? "device.leave" : "device.ask-leave"} data-armed={arming ? "" : undefined} onClick={leave}>
{arming ? t("remote.disconnect_2") : t("remote.disconnect")}
</button>
+7 -2
View File
@@ -4,10 +4,15 @@ import { useFolded } from "./usefold";
export function RailToggle({ rail, onRail }: { rail: boolean; onRail: () => void }) {
const drawer = useFolded("scene");
const phone = useFolded("phone");
if (!drawer && rail) return null;
// On a phone the rail is the list of sessions, and the control for it says so.
const label = phone ? t("app.session_list") : rail ? t("common.collapse_the_workspace_rail") : t("app.show_the_workspace_rail");
return (
<button className="studio-menu" data-action="chrome.rail" onClick={onRail} title={`${rail ? t("session.collapse_sidebar") : t("session.expand_sidebar")} ${chord("B")}`} aria-pressed={rail} aria-label={rail ? t("common.collapse_the_workspace_rail") : t("app.show_the_workspace_rail")}>
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="10" rx="1.5" /><path d="M6 3v10" /></svg>
<button className="studio-menu" data-action="chrome.rail" onClick={onRail} title={phone ? label : `${rail ? t("session.collapse_sidebar") : t("session.expand_sidebar")} ${chord("B")}`} aria-pressed={rail} aria-label={label}>
{phone
? <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 4.5h10M3 8h10M3 11.5h10" /></svg>
: <svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="10" rx="1.5" /><path d="M6 3v10" /></svg>}
</button>
);
}
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../testkit";
import { ApprovalCard } from "./ApprovalCard";
import { boot, STORAGE } from "../../i18n";
beforeEach(() => {
localStorage.setItem(STORAGE, "zh");
boot();
document.documentElement.dataset.fold = "rail side scene phone";
});
afterEach(() => {
cleanup();
delete document.documentElement.dataset.fold;
});
const card = (over: Record<string, unknown> = {}) =>
render(
<ApprovalCard
item={{ t: "approval", id: "i1", a: { id: "a1", tool: "bash", subject: "go test ./internal/retry/...", allowsSession: true, allowsPersist: true, ...over } } as never}
onApprove={async () => {}} onFullAccess={async () => {}} onPlan={async () => {}}
/>,
).container;
const footer = (box: HTMLElement) => box.querySelector<HTMLElement>(".apv-ft")!;
// On a phone the card is two big buttons and a way to the rest, so a thumb has
// one obvious thing to press; the rest stays one tap away rather than gone.
describe("the approval card's way to its other choices", () => {
it("offers 'more options' when the card has choices beyond allow and refuse", async () => {
const box = card();
const more = screen.getByRole("button", { name: "更多选项…" });
expect(more.getAttribute("aria-expanded")).toBe("false");
expect(footer(box).hasAttribute("data-more")).toBe(false);
await userEvent.click(more);
expect(more.getAttribute("aria-expanded")).toBe("true");
expect(footer(box).hasAttribute("data-more")).toBe(true);
await userEvent.click(more);
expect(footer(box).hasAttribute("data-more")).toBe(false);
});
it("offers nothing to open when there is nothing behind it", () => {
card({ allowsSession: false, allowsPersist: false });
expect(screen.queryByRole("button", { name: "更多选项…" })).toBeNull();
});
it("keeps allow and refuse as the two buttons of the first row, whatever else is open", async () => {
const box = card();
const row = [...footer(box).querySelectorAll<HTMLElement>(".btn[data-primary], .btn[data-deny]")];
expect(row.map((b) => b.textContent)).toEqual(expect.arrayContaining(["允许这一次", "拒绝"]));
await userEvent.click(screen.getByRole("button", { name: "更多选项…" }));
expect(footer(box).querySelectorAll(".btn[data-primary], .btn[data-deny]")).toHaveLength(2);
});
});
describe("the same card on a wider screen", () => {
it("draws every choice as a button and no 'more options' to open", () => {
delete document.documentElement.dataset.fold;
card();
expect(screen.queryByRole("button", { name: "更多选项…" })).toBeNull();
expect(screen.getByRole("button", { name: "允许这一次" })).toBeTruthy();
expect(screen.getByRole("button", { name: "拒绝" })).toBeTruthy();
});
});
@@ -6,6 +6,7 @@ import { useState } from "react";
import { decisionSource } from "../source";
import { RememberChoice, RememberWhere, type Remembered } from "./RememberChoice";
import { useViewer } from "../../state/viewer";
import { useFolded } from "../usefold";
import { isPlanGate } from "../planLive";
import { PlanCard } from "./PlanCard";
@@ -105,6 +106,8 @@ export function ApprovalCard({ item, onApprove, onFullAccess, onPlan }: Props) {
const settledBy = decisionSource(item.by, useViewer());
const [submitting, setSubmitting] = useState<ApprovalVerdict | "">("");
const [confirmFullAccess, setConfirmFullAccess] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const phone = useFolded("phone");
const [switchingFullAccess, setSwitchingFullAccess] = useState(false);
const remembering: Remembered[] = [...(item.a.allowsSession ? ["session" as const] : []), ...(item.a.allowsPersist ? ["always" as const] : [])];
// Full access cannot answer the origin question, so the card does not offer it.
@@ -161,7 +164,7 @@ export function ApprovalCard({ item, onApprove, onFullAccess, onPlan }: Props) {
</div>
)}
{!sealed && (
<div className="apv-ft">
<div className="apv-ft" data-more={moreOpen ? "" : undefined}>
<button className="btn" data-primary data-action="decision.tool" data-target={item.a.id} data-value="once"
disabled={!!submitting} onClick={() => void decide("once")}>
{submitting === "once" ? t("common.submitting") : t("chat.allow_once")}
@@ -181,6 +184,11 @@ export function ApprovalCard({ item, onApprove, onFullAccess, onPlan }: Props) {
{t("chat.switch_to_full_access")}
</button>
)}
{phone && (remembering.length > 0 || explicit) && (
<button type="button" className="apv-more" data-action="decision.more" aria-expanded={moreOpen} onClick={() => setMoreOpen((on) => !on)}>
{t("chat.more_options")}
</button>
)}
</div>
)}
{!sealed && <RememberWhere options={remembering} target={item.a.id} tool={item.a.tool} />}
@@ -12,8 +12,10 @@ describe("layout folds", () => {
expect(foldsAt(1200)).toBe("rail");
expect(foldsAt(840)).toBe("rail side");
expect(foldsAt(640)).toBe("rail side scene");
expect(foldsAt(301)).toBe("rail side scene");
expect(foldsAt(300)).toBe("rail side scene bar");
expect(foldsAt(521)).toBe("rail side scene");
expect(foldsAt(520)).toBe("rail side scene phone");
expect(foldsAt(301)).toBe("rail side scene phone");
expect(foldsAt(300)).toBe("rail side scene phone bar");
});
it("accumulates, so a narrower window never un-folds a wider one's column", () => {
+1
View File
@@ -12,6 +12,7 @@ const FOLDS = [
{ upTo: 1200, fold: "rail" }, // the workspace column goes
{ upTo: 840, fold: "side" }, // metrics moves under the flow
{ upTo: 640, fold: "scene" }, // the opening scene tightens
{ upTo: 520, fold: "phone" }, // a phone: the bar keeps the session list, the title and one menu
{ upTo: 300, fold: "bar" }, // the bar's controls gather into one: unfolded they leave the title under its 4em floor
] as const;