fix(studio): draw each rail entry with the mark of the settings section it opens

The icon rail kept its own icon set, so the same destination showed one mark
on the rail and another on the page it opens. Entries that open a section now
read that section's mark from the settings table; only the conversation item
keeps its own.
This commit is contained in:
yhh committed 2026-10-11 20:33:09 -07:00
1 parent 4a0b3888cb
commit 4c3eabc0ab
3 files changed
+43 -22

No files matched your search

@@ -0,0 +1,29 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { Nav } from "./Nav";
import { ICON, type Section } from "./prefsnav";
const props = { at: null, onGo: vi.fn(), onHome: vi.fn(), onRail: vi.fn(), onFeedback: vi.fn(), feedbackUnread: 0, shown: true };
afterEach(cleanup);
const sectionMark = (section: Section) => render(<svg>{ICON[section]}</svg>).container.querySelector("svg")?.innerHTML;
describe("the icon rail draws the mark of the section it opens", () => {
it.each([
["用量", "usage"],
["文件", "storage"],
["工具与权限", "tools"],
["扩展", "ext"],
["记忆", "memory"],
["远程", "remote"],
["账号", "account"],
["设置", "advanced"],
] as const)("%s shows the %s section mark", (label, section) => {
const { container } = render(<Nav {...props} />);
const btn = container.querySelector(`button[aria-label="${label}"]`);
expect(btn).not.toBeNull();
expect(btn?.querySelector("svg")?.innerHTML).toBe(sectionMark(section));
});
});
+12 -20
View File
@@ -1,37 +1,29 @@
import { t } from "../i18n";
import { FeedbackBadge } from "./feedbackentry";
import { ICON, type Section } from "./prefsnav";
import { StudioIcon } from "./StudioIcon";
// Drawn on the same 16-unit grid at the same 1.5 stroke as Sym's marks, and for
// the same reason: an icon set pulled in from outside reads as a different hand
// next to the ones the transcript already draws.
const ICON: Record<string, string> = {
chat: "M3.2 4.4a1.2 1.2 0 0 1 1.2-1.2h7.2a1.2 1.2 0 0 1 1.2 1.2v5a1.2 1.2 0 0 1-1.2 1.2H7.2L4.4 13v-2.4a1.2 1.2 0 0 1-1.2-1.2Z",
usage: "M3.6 12.4V8.6M7 12.4V4.4M10.4 12.4V7M13 12.4V9.8",
tools: "M8 2.6 12.5 4.3v4c0 2.6-1.9 4.3-4.5 5-2.6-.7-4.5-2.4-4.5-5v-4ZM6.2 8.1l1.4 1.4 2.4-2.7",
ext: "M8 2.8 13.2 8 8 13.2 2.8 8Z",
memory: "M4.6 3h6.8v10L8 10.5 4.6 13Z",
remote: "M2.9 4.3a1.2 1.2 0 0 1 1.2-1.2h7.8a1.2 1.2 0 0 1 1.2 1.2v5.1a1.2 1.2 0 0 1-1.2 1.2H4.1a1.2 1.2 0 0 1-1.2-1.2ZM6.4 13h3.2M8 10.6V13",
account: "M8 3.2a2.3 2.3 0 1 0 0 4.6 2.3 2.3 0 0 0 0-4.6M3.9 13v-.9A2.9 2.9 0 0 1 6.8 9.2h2.4a2.9 2.9 0 0 1 2.9 2.9v.9",
files: "M4.5 2.8h4.8L12 5.5v7.7H4.5ZM9.3 2.8v2.7H12",
settings: "M3 5h5.2M11.2 5H13M3 11h2.2M8.2 11H13M9.4 3.4v3.2M6.4 9.4v3.2",
};
// An entry that opens a settings section draws that section's mark from prefsnav,
// so the rail and the page it opens never show two icons for one destination. Only
// the conversation item, which opens no section, keeps a mark of its own.
const CHAT = "M3.2 4.4a1.2 1.2 0 0 1 1.2-1.2h7.2a1.2 1.2 0 0 1 1.2 1.2v5a1.2 1.2 0 0 1-1.2 1.2H7.2L4.4 13v-2.4a1.2 1.2 0 0 1-1.2-1.2Z";
type Mark = "chat" | Section;
// Every entry goes somewhere that exists. A rail of icons that open nothing is
// the cheapest thing to draw and the most expensive thing to keep.
const TO: [keyof typeof ICON, string, string | null][] = [
const TO: [Mark, string, string | null][] = [
["chat", "会话", null],
["usage", "用量", "usage"],
["files", "文件", "storage"],
["storage", "文件", "storage"],
["tools", "工具与权限", "tools"],
["ext", "扩展", "ext"],
["memory", "记忆", "memory"],
["remote", "远程", "remote"],
];
const FOOT: [keyof typeof ICON, string, string, string][] = [
const FOOT: [Mark, string, string, string][] = [
["account", "账号", "account", "chrome.account"],
["settings", "设置", "", "chrome.settings"],
["advanced", "设置", "", "chrome.settings"],
];
interface Props {
@@ -48,7 +40,7 @@ interface Props {
}
export function Nav({ at, onGo, onHome, onRail, onFeedback, feedbackUnread, shown }: Props) {
const btn = ([key, label, sec, action]: [keyof typeof ICON, string, string | null, string?]) => (
const btn = ([key, label, sec, action]: [Mark, string, string | null, string?]) => (
<button
key={key}
className="navbtn"
@@ -58,7 +50,7 @@ export function Nav({ at, onGo, onHome, onRail, onFeedback, feedbackUnread, show
onClick={() => (sec === null ? (at === null ? onRail() : onHome()) : onGo(sec || undefined))}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d={ICON[key]} />
{key === "chat" ? <path d={CHAT} /> : ICON[key]}
</svg>
<span className="navhint" role="tooltip">{t(label)}</span>
</button>
+2 -2
View File
@@ -7,8 +7,8 @@ import { t } from "../i18n";
export type Section = "session" | "model" | "providers" | "tools" | "hooks" | "ext" | "network" | "remote" | "memory" | "usage" | "storage" | "account" | "versions" | "appearance" | "advanced";
// Drawn on the same 16-unit grid at 1.45 stroke as the rest of this screen's
// marks. The rail in Nav.tsx keeps its own set on purpose: those name panes to
// open, these name sections of one page, and the two lists barely overlap.
// marks. The rail in Nav.tsx draws a section's entry from this table, so one
// destination carries one mark wherever it is shown.
export const ICON: Record<Section, ReactNode> = {
session: <path d="M2.6 8h10.8M8 2.6v10.8" />,
model: (