fix(studio): use the divider as the desktop sidebar control
Problem: The desktop sidebar has a redundant top hamburger button alongside its divider, making the sidebar affordances ambiguous. Root cause: The drawer entry is mounted in all layouts, although desktop layouts already expose a divider that toggles and resizes the workspace rail. Fix: Mount the top entry only under the scene fold, remove its desktop chrome spacing, and preserve the existing divider and remembered width. Document the intentional control removal and include light/dark responsive screenshots. Verification: 488 frontend test files and 4141 tests pass, including divider interaction and responsive boundary coverage. Typecheck, production build, repolint, UI census and fixture verification pass. Browser interactions and an isolated macOS Electron window verify the retained controls. Refs #12917
No files matched your search
@@ -96,10 +96,12 @@ rules are written in.
|
||||
- The separator between crumbs is a written character, not the 1px hairline
|
||||
`.sep`. Sharing that name puts the glyph in a 1px-wide box with a background,
|
||||
and the strokes spill out of it.
|
||||
- The sidebar has one toggle, and it does not move.
|
||||
- `.studio-menu` is fixed to the window at `--lights-w + 10px`, above the rail column whatever the rail and the icon
|
||||
column are doing; the sidebar header carries no second button.
|
||||
- The bar's content and the sidebar's brand row reserve room for it (`--nav-w` is what the icon column already gives).
|
||||
- The desktop sidebar's divider owns collapse, expansion and width adjustment.
|
||||
- Its grip remains visible when collapsed; the separator supports Enter/Space for toggling, arrow keys for sizing,
|
||||
and Home or a double click for restoring the default width. The open width survives collapse and reload.
|
||||
- Under the `scene` fold the divider is hidden and the sidebar becomes a drawer. Only this layout mounts
|
||||
`.studio-menu`, fixed at `--lights-w + 10px` after the shell's drag regions; the sidebar header has no second button.
|
||||
- `--rail-toggle-gap` reserves title-bar space only for the drawer entry; desktop chrome reserves the native lights alone.
|
||||
- The icon rail's conversation item toggles the sidebar when the conversation is showing, and returns to it from
|
||||
settings; the sidebar's quick entries are drawn only while the rail is not.
|
||||
- A run of replies from one model is headed once (`data-quiet`); a change of model, or a user message, brings the
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
--chrome-h: 51px; --lights-w: 96px; --lights-h: var(--chrome-h);
|
||||
}
|
||||
[data-titlebar="app"][data-platform="darwin"] .app[data-rail="off"] .chrome {
|
||||
padding-left: calc(var(--lights-w) + 50px - var(--nav-w));
|
||||
padding-left: calc(var(--lights-w) + var(--rail-toggle-gap) - var(--nav-w));
|
||||
}
|
||||
[data-titlebar="app"][data-platform="darwin"] .app[data-rail="on"] .studio-rail-head {
|
||||
padding-top: var(--lights-h); -webkit-app-region: drag;
|
||||
|
||||
@@ -458,7 +458,7 @@ body { background: var(--page) !important; }
|
||||
.chrome {
|
||||
width: auto;
|
||||
margin-left: calc(var(--nav-w) + var(--rail-w));
|
||||
padding: 0 18px 0 max(20px, calc(var(--lights-w) + 50px - var(--nav-w) - var(--rail-w)));
|
||||
padding: 0 18px 0 max(20px, calc(var(--lights-w) + var(--rail-toggle-gap) - var(--nav-w) - var(--rail-w)));
|
||||
gap: 11px;
|
||||
background: var(--surface) !important;
|
||||
border-bottom: 1px solid var(--hair);
|
||||
@@ -468,7 +468,8 @@ body { background: var(--page) !important; }
|
||||
.panes { background: var(--surface) !important; }
|
||||
.tabs { display: flex !important; }
|
||||
.railhost { display: block; }
|
||||
.app { --srail-w: 36px; }
|
||||
.app { --srail-w: 36px; --rail-toggle-gap: 0px; }
|
||||
[data-fold~="scene"] .app { --rail-toggle-gap: 50px; }
|
||||
|
||||
/* Prototype header */
|
||||
.studio-menu { position: fixed; z-index: 39; left: calc(var(--lights-w) + 10px); top: calc((var(--chrome-h) - 30px) / 2); width: 30px; height: 30px; display: grid; place-items: center; padding: 0; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--faint); cursor: pointer; }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { DOCK, Gutter } from "./Gutter";
|
||||
import { DOCK, Gutter, RAIL } from "./Gutter";
|
||||
|
||||
const pointer = (type: string, x: number) => new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, button: 0 }) as MouseEvent & { pointerId: number };
|
||||
const send = (el: Element, type: string, x: number) => {
|
||||
@@ -11,13 +11,14 @@ const send = (el: Element, type: string, x: number) => {
|
||||
fireEvent(el, ev);
|
||||
};
|
||||
|
||||
function mount(open: boolean) {
|
||||
function mount(open: boolean, edge: "l" | "r" = "r") {
|
||||
const span = edge === "l" ? RAIL : DOCK;
|
||||
const onOpen = vi.fn();
|
||||
const onWidth = vi.fn();
|
||||
const view = render(
|
||||
<div className="app">
|
||||
<section className="pane">
|
||||
<Gutter edge="r" span={DOCK} width={560} label="resize" open={open} onWidth={onWidth} onOpen={onOpen} />
|
||||
<Gutter edge={edge} span={span} width={span.def} label="resize" open={open} onWidth={onWidth} onOpen={onOpen} />
|
||||
</section>
|
||||
</div>,
|
||||
);
|
||||
@@ -32,9 +33,11 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("a column's drag handle", () => {
|
||||
describe.each(["l", "r"] as const)("the %s column's drag handle", (edge) => {
|
||||
const renderHandle = (open: boolean) => mount(open, edge);
|
||||
const widening = edge === "l" ? 1 : -1;
|
||||
it("opens a shut column when its grip is clicked", () => {
|
||||
const { grip, onOpen, app } = mount(false);
|
||||
const { grip, onOpen, app } = renderHandle(false);
|
||||
send(grip, "pointerdown", 500);
|
||||
send(grip, "pointerup", 500);
|
||||
fireEvent.click(grip);
|
||||
@@ -43,30 +46,46 @@ describe("a column's drag handle", () => {
|
||||
});
|
||||
|
||||
it("starts a drag from the grip only once the press has travelled", () => {
|
||||
const { grip, bar, app, onOpen } = mount(false);
|
||||
const { grip, bar, app, onOpen } = renderHandle(false);
|
||||
send(grip, "pointerdown", 500);
|
||||
send(grip, "pointermove", 498);
|
||||
expect(app.dataset.drag).toBeUndefined();
|
||||
send(grip, "pointermove", 380);
|
||||
send(grip, "pointermove", 500 + widening * 120);
|
||||
expect(app.dataset.drag).toBe("pointer");
|
||||
send(bar, "pointermove", 370);
|
||||
send(bar, "pointerup", 370);
|
||||
send(bar, "pointermove", 500 + widening * 130);
|
||||
send(bar, "pointerup", 500 + widening * 130);
|
||||
expect(app.dataset.drag).toBeUndefined();
|
||||
expect(onOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("still drags from the strip beside the grip", () => {
|
||||
const { bar, app, onWidth } = mount(true);
|
||||
const { bar, app, onWidth } = renderHandle(true);
|
||||
send(bar, "pointerdown", 500);
|
||||
expect(app.dataset.drag).toBe("pointer");
|
||||
send(bar, "pointermove", 450);
|
||||
send(bar, "pointerup", 450);
|
||||
expect(onWidth).toHaveBeenCalled();
|
||||
send(bar, "pointermove", 500 + widening * 50);
|
||||
send(bar, "pointerup", 500 + widening * 50);
|
||||
expect(onWidth).toHaveBeenCalledWith((edge === "l" ? RAIL : DOCK).def + 50);
|
||||
});
|
||||
|
||||
it("toggles from the keyboard", () => {
|
||||
const { bar, onOpen } = mount(true);
|
||||
const { bar, onOpen } = renderHandle(true);
|
||||
fireEvent.keyDown(bar, { key: "Enter" });
|
||||
expect(onOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("closes from the grip without changing the remembered width", () => {
|
||||
const { grip, onOpen, onWidth } = renderHandle(true);
|
||||
fireEvent.click(grip);
|
||||
expect(onOpen).toHaveBeenCalledWith(false);
|
||||
expect(onWidth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports keyboard sizing and restoring the default width", () => {
|
||||
const { bar, onWidth } = renderHandle(true);
|
||||
const span = edge === "l" ? RAIL : DOCK;
|
||||
fireEvent.keyDown(bar, { key: widening === 1 ? "ArrowRight" : "ArrowLeft" });
|
||||
expect(onWidth).toHaveBeenLastCalledWith(span.def + 12);
|
||||
fireEvent.keyDown(bar, { key: "Home" });
|
||||
expect(onWidth).toHaveBeenLastCalledWith(span.def);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { t } from "../i18n";
|
||||
import { useFolded } from "./usefold";
|
||||
|
||||
export function RailToggle({ rail, onRail }: { rail: boolean; onRail: () => void }) {
|
||||
const drawer = useFolded("scene");
|
||||
if (!drawer) return null;
|
||||
return (
|
||||
<button className="studio-menu" data-action="chrome.rail" onClick={onRail} aria-pressed={rail} aria-label={rail ? t("收起工作区栏") : t("展开工作区栏")}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h10" /></svg>
|
||||
|
||||
@@ -24,6 +24,7 @@ beforeEach(async () => {
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
room(1440);
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -55,7 +56,7 @@ async function open() {
|
||||
const nav = () => screen.queryByRole("navigation", { name: "主导航" });
|
||||
const navEl = () => document.querySelector<HTMLElement>("nav.nav");
|
||||
const app = () => document.querySelector<HTMLElement>(".app")!;
|
||||
const toggleSidebar = () => userEvent.click(document.querySelector<HTMLElement>(".studio-menu")!);
|
||||
const toggleSidebar = () => userEvent.click(document.querySelector<HTMLElement>(".gutter-l .grip")!);
|
||||
|
||||
async function openAppearance() {
|
||||
await userEvent.click(screen.getByRole("button", { name: "沙盒" }));
|
||||
@@ -220,39 +221,86 @@ it("the conversation item opens and closes the workspace column, and from settin
|
||||
expect(app().dataset.rail).toBe("on");
|
||||
});
|
||||
|
||||
it("keeps the sidebar toggle working while the settings sheet is over the window", async () => {
|
||||
it("keeps desktop navigation usable after closing settings without a top toggle", async () => {
|
||||
await open();
|
||||
const toggle = () => document.querySelector<HTMLButtonElement>(".studio-menu")!;
|
||||
await userEvent.click(within(navEl()!).getByRole("button", { name: /^用量/ }));
|
||||
expect(toggle().disabled).toBe(false);
|
||||
expect(document.querySelector(".studio-menu")).toBeNull();
|
||||
expect(app().dataset.rail).toBe("on");
|
||||
await userEvent.click(toggle());
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await toggleSidebar();
|
||||
expect(app().dataset.rail).toBe("off");
|
||||
await userEvent.click(toggle());
|
||||
await toggleSidebar();
|
||||
expect(app().dataset.rail).toBe("on");
|
||||
await userEvent.click(within(navEl()!).getByRole("button", { name: /^会话/ }));
|
||||
expect(toggle().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("declares the sidebar toggle after every region the shell lets the window be dragged by", async () => {
|
||||
it("declares the narrow drawer toggle after the shell's drag regions", async () => {
|
||||
room(390);
|
||||
await open();
|
||||
const toggle = document.querySelector<HTMLElement>(".studio-menu")!;
|
||||
for (const sel of [".chrome", ".nav", ".rail"]) {
|
||||
expect(toggle).not.toBeNull();
|
||||
for (const sel of [".chrome", ".rail"]) {
|
||||
const region = document.querySelector<HTMLElement>(sel)!;
|
||||
expect(region, sel).not.toBeNull();
|
||||
expect(region.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING, `${sel} must come before the toggle`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps one sidebar toggle, and the sidebar does not repeat what the rail already holds", async () => {
|
||||
it("uses only the divider to collapse and reopen the desktop sidebar", async () => {
|
||||
await open();
|
||||
const toggles = screen.getAllByRole("button", { name: /收起工作区栏|展开工作区栏/ }).filter((b) => b.tabIndex !== -1);
|
||||
expect(toggles).toHaveLength(1);
|
||||
expect(toggles[0].classList.contains("studio-menu")).toBe(true);
|
||||
const toggles = screen.queryAllByRole("button", { name: /收起工作区栏|展开工作区栏/ }).filter((b) => b.tabIndex !== -1);
|
||||
expect(toggles).toHaveLength(0);
|
||||
expect(document.querySelector(".studio-menu")).toBeNull();
|
||||
expect(document.querySelector(".studio-quicknav")).toBeNull();
|
||||
await toggleSidebar();
|
||||
expect(app().dataset.rail).toBe("off");
|
||||
expect(screen.getByRole("separator", { name: "调整工作区栏宽度" }).getAttribute("aria-valuenow")).toBe("0");
|
||||
await toggleSidebar();
|
||||
expect(screen.getAllByRole("button", { name: /收起工作区栏|展开工作区栏/ }).filter((b) => b.tabIndex !== -1)).toHaveLength(1);
|
||||
expect(app().dataset.rail).toBe("on");
|
||||
expect(document.querySelector(".studio-menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("remembers divider width through collapse, reopen, and reload with the icon rail off", async () => {
|
||||
localStorage.setItem(KEY, "off");
|
||||
const first = await open();
|
||||
const divider = screen.getByRole("separator", { name: "调整工作区栏宽度" });
|
||||
divider.focus();
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(divider.getAttribute("aria-valuenow")).toBe("276");
|
||||
expect(localStorage.getItem("rx-rail-w")).toBe("276");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(app().dataset.rail).toBe("off");
|
||||
await userEvent.keyboard(" ");
|
||||
expect(app().dataset.rail).toBe("on");
|
||||
expect(divider.getAttribute("aria-valuenow")).toBe("276");
|
||||
first.view.unmount();
|
||||
await open();
|
||||
expect(screen.getByRole("separator", { name: "调整工作区栏宽度" }).getAttribute("aria-valuenow")).toBe("276");
|
||||
});
|
||||
|
||||
it.each(["on", "off"])("keeps one drawer entry at the scene boundary with the icon rail %s", async (mode) => {
|
||||
localStorage.setItem(KEY, mode);
|
||||
await open();
|
||||
act(() => room(641));
|
||||
expect(document.querySelector(".studio-menu")).toBeNull();
|
||||
act(() => room(640));
|
||||
const toggle = await screen.findByRole("button", { name: "展开工作区栏" });
|
||||
expect(document.querySelectorAll(".studio-menu")).toHaveLength(1);
|
||||
await userEvent.click(toggle);
|
||||
expect(app().dataset.rail).toBe("on");
|
||||
expect(toggle.getAttribute("aria-label")).toBe("收起工作区栏");
|
||||
await userEvent.click(toggle);
|
||||
expect(app().dataset.rail).toBe("off");
|
||||
act(() => room(390));
|
||||
await userEvent.click(screen.getByRole("button", { name: "展开工作区栏" }));
|
||||
expect(app().dataset.rail).toBe("on");
|
||||
act(() => room(1440));
|
||||
expect(document.querySelector(".studio-menu")).toBeNull();
|
||||
expect(app().dataset.rail).toBe("on");
|
||||
await toggleSidebar();
|
||||
act(() => room(390));
|
||||
act(() => room(1440));
|
||||
expect(app().dataset.rail).toBe("off");
|
||||
expect(document.querySelector(".studio-menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("with the rail off the sidebar keeps its own quick entries", async () => {
|
||||
|
||||
@@ -43,6 +43,7 @@ Checked against `studio` at `d779d89bc` on 2026-10-08.
|
||||
| Session recovery versions dialog | Unverified | Not searched by name; see #12365 for the delete behaviour |
|
||||
| Remote (SSH) workspaces | Have | `ui/Remotes.tsx`, `ui/RemoteHosts.tsx` |
|
||||
| Session takeover when another window holds it | Have | `ui/App.tsx`, `ui/Pane.tsx` |
|
||||
| Workspace sidebar expansion, collapse and resizing | Have differently | Desktop uses the divider in `ui/Gutter.tsx`, including after collapse; the top button in `ui/RailToggle.tsx` is reserved for the narrow `scene` drawer layout (#12917) |
|
||||
|
||||
## Reference: composer and transcript
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -1 +1,5 @@
|
||||
下一版的变更(待发布) / Changes for the next release.
|
||||
|
||||
## 变更 / Changes
|
||||
|
||||
- 桌面布局移除顶部用于开关工作区侧栏的三横线按钮;分隔条继续支持展开、收起和宽度调整,窄窗口仍保留顶部抽屉入口。 / Remove the desktop workspace-sidebar hamburger button; the divider still expands, collapses and resizes the sidebar, and narrow windows keep the top drawer entry (#12917).
|
||||