Merge pull request #12918 from SivanCola/codex/studio-sidebar-divider

fix(studio): use the divider as the desktop sidebar control / 桌面侧栏统一使用分隔条操作
This commit is contained in:
YHH authored and GitHub committed 2026-10-13 05:58:45 -07:00
commit 6fee91ef4e
16 files changed
+112 -36

No files matched your search

+6 -4
View File
@@ -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
+1 -1
View File
@@ -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;
+3 -2
View File
@@ -454,7 +454,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);
@@ -464,7 +464,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 () => {
+1
View File
@@ -45,6 +45,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
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+2
View File
@@ -3,6 +3,8 @@
- 会话列表统一为 34px 单行省略,去掉额外行间距,悬浮可查看完整标题。“选择本机会话”入口移至“这台机器”的更多菜单,批量归档和删除功能保留。Refs #10651.
- Session rows use 34px single-line ellipsis with full titles on hover.
“Select local sessions” moves to this machine's More actions menu; batch archive and delete remain available. Refs #10651.
- 桌面布局移除顶部用于开关工作区侧栏的三横线按钮;分隔条继续支持展开、收起和宽度调整,窄窗口仍保留顶部抽屉入口 (#12917)。
- 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).
- 外观新增「清白蓝」「茶绿」,支持浅色、深色及跟随系统,默认主题不变。修复默认预览串色和控件配色不一致。关联 #12909。
- Adds Clear Blue and Tea Green in light/dark and system modes, preserving the default. Fixes default-preview tint leakage and inconsistent control colours. Refs #12909.
- 动态天空主题移至「更多已安装主题」,激活的背景主题仍在首组显示。关联 #12909。