diff --git a/desktop/frontend-next/src/design/design.css b/desktop/frontend-next/src/design/design.css index 157ed4ab21..f8a1a13776 100644 --- a/desktop/frontend-next/src/design/design.css +++ b/desktop/frontend-next/src/design/design.css @@ -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); diff --git a/desktop/frontend-next/src/design/phone.css b/desktop/frontend-next/src/design/phone.css new file mode 100644 index 0000000000..d6badae840 --- /dev/null +++ b/desktop/frontend-next/src/design/phone.css @@ -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; } diff --git a/desktop/frontend-next/src/design/phone.test.ts b/desktop/frontend-next/src/design/phone.test.ts new file mode 100644 index 0000000000..c2b6823969 --- /dev/null +++ b/desktop/frontend-next/src/design/phone.test.ts @@ -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\)/); + }); +}); diff --git a/desktop/frontend-next/src/i18n/msg/app.ts b/desktop/frontend-next/src/i18n/msg/app.ts index 6408392b9b..4850661331 100644 --- a/desktop/frontend-next/src/i18n/msg/app.ts +++ b/desktop/frontend-next/src/i18n/msg/app.ts @@ -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" }, diff --git a/desktop/frontend-next/src/i18n/msg/chat.ts b/desktop/frontend-next/src/i18n/msg/chat.ts index 3016ee530a..7846523298 100644 --- a/desktop/frontend-next/src/i18n/msg/chat.ts +++ b/desktop/frontend-next/src/i18n/msg/chat.ts @@ -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." }, diff --git a/desktop/frontend-next/src/i18n/msg/remote.ts b/desktop/frontend-next/src/i18n/msg/remote.ts index 80f99a5746..f25226e068 100644 --- a/desktop/frontend-next/src/i18n/msg/remote.ts +++ b/desktop/frontend-next/src/i18n/msg/remote.ts @@ -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" }, diff --git a/desktop/frontend-next/src/state/viewer.ts b/desktop/frontend-next/src/state/viewer.ts index 2f915ef3ed..22cc73d260 100644 --- a/desktop/frontend-next/src/state/viewer.ts +++ b/desktop/frontend-next/src/state/viewer.ts @@ -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) => { diff --git a/desktop/frontend-next/src/styles/narrow.css b/desktop/frontend-next/src/styles/narrow.css index e873aa5e5c..ba12e0a14e 100644 --- a/desktop/frontend-next/src/styles/narrow.css +++ b/desktop/frontend-next/src/styles/narrow.css @@ -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; } diff --git a/desktop/frontend-next/src/ui/Chrome.phone.interaction.test.tsx b/desktop/frontend-next/src/ui/Chrome.phone.interaction.test.tsx new file mode 100644 index 0000000000..b3f0e4cfc4 --- /dev/null +++ b/desktop/frontend-next/src/ui/Chrome.phone.interaction.test.tsx @@ -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 = {}) => + ({ workspaceRoot: "D:/work/DeepSeek-Reasonix", running: false, ...over }) as SessionStatus; + +function bar(over: Partial = {}) { + return render( + {}} 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(".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( {}} />); + expect(screen.getByRole("button", { name: "会话列表" })).toBeTruthy(); + view.unmount(); + atFold("rail side scene"); + render( {}} />); + expect(screen.queryByRole("button", { name: "会话列表" })).toBeNull(); + }); +}); diff --git a/desktop/frontend-next/src/ui/Chrome.tsx b/desktop/frontend-next/src/ui/Chrome.tsx index 66ea4b32d5..3256eb8583 100644 --- a/desktop/frontend-next/src/ui/Chrome.tsx +++ b/desktop/frontend-next/src/ui/Chrome.tsx @@ -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(null); const shut = useCallback(() => setMore(false), []); @@ -103,6 +107,16 @@ export function Chrome({ port, status, workspace: ws, title, steer, account, hos / {sessionName(title, status?.sessionPath)} + {phone && device && ( + +