feat(web): collapsed-rail nav with last conversation, new chat, and localized tooltips (#36)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+260
-89
No files matched your search
@@ -9,6 +9,10 @@
|
|||||||
* (the group header's provider name used to get pushed out of the button box and overlap
|
* (the group header's provider name used to get pushed out of the button box and overlap
|
||||||
* the group-level actions);
|
* the group-level actions);
|
||||||
* - the sidebar's "New chat" button has no background fill (same gray-scale style as nav items);
|
* - the sidebar's "New chat" button has no background fill (same gray-scale style as nav items);
|
||||||
|
* - the collapsed rail shows, in product-specified order, last conversation / new chat /
|
||||||
|
* Agents / Skills / Models / Costs / Traces / Benchmark with localized (en + zh) hover
|
||||||
|
* tooltips; "last conversation" targets the newest non-archived session and is disabled
|
||||||
|
* while none exists; expanding from the rail restores the pinned sidebar;
|
||||||
* - login page: a single brand penguin logo above the form (part of the form area; the
|
* - login page: a single brand penguin logo above the form (part of the form area; the
|
||||||
* background still only has the trace animation), the trace animation grows in after a
|
* background still only has the trace animation), the trace animation grows in after a
|
||||||
* delayed blank first paint, no two trace segments cross or touch (except where a fork shares
|
* delayed blank first paint, no two trace segments cross or touch (except where a fork shares
|
||||||
@@ -127,6 +131,115 @@ test("layout: en draft + context gauge + mobile models", async ({ page }) => {
|
|||||||
).toBe("rgba(0, 0, 0, 0)");
|
).toBe("rgba(0, 0, 0, 0)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("layout: collapsed rail — order, bilingual tooltips, last conversation", async ({ page }) => {
|
||||||
|
await page.addInitScript(() => localStorage.setItem("penguin.lang", "en"));
|
||||||
|
await provisionAndLogin(page.request, "railuser", P);
|
||||||
|
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||||
|
const projectId = projects.projects[0].projectId;
|
||||||
|
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||||
|
data: {
|
||||||
|
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||||
|
models: [{ provider: "custom", modelId: "claude-4-8", apiKey: "sk-mock" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(put.ok(), "put models").toBeTruthy();
|
||||||
|
|
||||||
|
// --- No sessions yet: the rail renders all 8 entries, "last conversation" disabled ---
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
await page.goto(`${BASE}/chat`);
|
||||||
|
await page.getByRole("button", { name: "Collapse sidebar" }).click();
|
||||||
|
const rail = page.locator("aside nav");
|
||||||
|
const entries = rail.locator("a, button");
|
||||||
|
await expect(entries).toHaveCount(8);
|
||||||
|
await expect(rail.getByRole("button", { name: "Last conversation" })).toBeDisabled();
|
||||||
|
|
||||||
|
// --- Order and tooltips (en): aria-label defines the order, title carries the same copy ---
|
||||||
|
const EN = [
|
||||||
|
"Last conversation",
|
||||||
|
"New chat",
|
||||||
|
"Agents",
|
||||||
|
"Skills",
|
||||||
|
"Models",
|
||||||
|
"Costs",
|
||||||
|
"Trajectory",
|
||||||
|
"Evaluation Center",
|
||||||
|
];
|
||||||
|
const attrs = (name) =>
|
||||||
|
entries.evaluateAll((els, n) => els.map((el) => el.getAttribute(n)), name);
|
||||||
|
expect(await attrs("aria-label"), "rail order (en)").toEqual(EN);
|
||||||
|
expect(await attrs("title"), "rail tooltips (en)").toEqual(EN);
|
||||||
|
|
||||||
|
// --- Three sessions via the API (distinct createdAt), newest archived: the rail must target the newest *non-archived* one ---
|
||||||
|
const mkSession = async () => {
|
||||||
|
const res = await page.request.post(
|
||||||
|
`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`,
|
||||||
|
{ data: { provider: "custom", modelId: "claude-4-8" } },
|
||||||
|
);
|
||||||
|
expect(res.ok(), "create session").toBeTruthy();
|
||||||
|
return (await res.json()).session.sessionId;
|
||||||
|
};
|
||||||
|
await mkSession();
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
const target = await mkSession();
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
const archived = await mkSession();
|
||||||
|
const patched = await page.request.patch(`${BASE}/api/sessions/${archived}`, {
|
||||||
|
data: { archived: true },
|
||||||
|
});
|
||||||
|
expect(patched.ok(), "archive newest session").toBeTruthy();
|
||||||
|
await page.reload(); // the session store loads on mount; the collapsed state persists via localStorage
|
||||||
|
|
||||||
|
// A click during the fetch window is a graceful no-op (the entry stays enabled while the
|
||||||
|
// list loads), so retry click+assert until the store has settled.
|
||||||
|
await expect(async () => {
|
||||||
|
await rail.getByRole("button", { name: "Last conversation" }).click();
|
||||||
|
await expect(page, "last conversation → newest non-archived session").toHaveURL(
|
||||||
|
`${BASE}/chat/${target}`,
|
||||||
|
{ timeout: 1000 },
|
||||||
|
);
|
||||||
|
}).toPass({ timeout: 15_000 });
|
||||||
|
// Active fill = the *unprefixed* bg-gray-200/70 token (the resting state carries hover:bg-gray-200/70, which a bare substring match would also hit).
|
||||||
|
const ACTIVE_FILL = /(^|\s)bg-gray-200\/70(\s|$)/;
|
||||||
|
// On a conversation, the entry lights as "you are here" (any non-draft /chat/:id).
|
||||||
|
await expect(rail.getByRole("button", { name: "Last conversation" })).toHaveClass(ACTIVE_FILL);
|
||||||
|
|
||||||
|
// --- New chat enters the draft page and shows the rail's gray active fill there ---
|
||||||
|
await rail.getByRole("button", { name: "New chat" }).click();
|
||||||
|
await expect(page).toHaveURL(`${BASE}/chat/new`);
|
||||||
|
await expect(rail.getByRole("button", { name: "New chat" })).toHaveClass(ACTIVE_FILL);
|
||||||
|
// The draft belongs to the new-chat entry: the last-conversation one must not stay lit here.
|
||||||
|
await expect(rail.getByRole("button", { name: "Last conversation" })).not.toHaveClass(
|
||||||
|
ACTIVE_FILL,
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Page entries navigate and highlight like the pinned nav ---
|
||||||
|
await rail.getByRole("link", { name: "Skills" }).click();
|
||||||
|
await expect(page).toHaveURL(`${BASE}/skills`);
|
||||||
|
await expect(rail.getByRole("link", { name: "Skills" })).toHaveClass(ACTIVE_FILL);
|
||||||
|
|
||||||
|
// --- zh: tooltips follow the product-specified wording ---
|
||||||
|
await page.addInitScript(() => localStorage.setItem("penguin.lang", "zh"));
|
||||||
|
await page.reload();
|
||||||
|
await expect(entries).toHaveCount(8);
|
||||||
|
const ZH = [
|
||||||
|
"最近一次对话",
|
||||||
|
"新建对话",
|
||||||
|
"智能体",
|
||||||
|
"技能库",
|
||||||
|
"模型仓库",
|
||||||
|
"成本中心",
|
||||||
|
"轨迹观测",
|
||||||
|
"评估中心",
|
||||||
|
];
|
||||||
|
expect(await attrs("aria-label"), "rail order (zh)").toEqual(ZH);
|
||||||
|
expect(await attrs("title"), "rail tooltips (zh)").toEqual(ZH);
|
||||||
|
|
||||||
|
// --- Expand: the rail's top button (localized) restores the pinned sidebar ---
|
||||||
|
await page.getByRole("button", { name: "展开侧栏" }).click();
|
||||||
|
await expect(page.locator("aside")).toHaveClass(/w-64/);
|
||||||
|
await expect(page.getByRole("button", { name: "收起侧栏" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("layout: login — blank start, non-crossing traces, lang/theme controls", async ({ page }) => {
|
test("layout: login — blank start, non-crossing traces, lang/theme controls", async ({ page }) => {
|
||||||
await page.goto(`${BASE}/login`);
|
await page.goto(`${BASE}/login`);
|
||||||
await page.getByRole("heading", { name: "PenguinHarness" }).waitFor();
|
await page.getByRole("heading", { name: "PenguinHarness" }).waitFor();
|
||||||
|
|||||||
@@ -4,35 +4,144 @@
|
|||||||
* - <md: top thin bar (hamburger -> sidebar drawer + brand name) + main content.
|
* - <md: top thin bar (hamburger -> sidebar drawer + brand name) + main content.
|
||||||
* All chrome uses solid backgrounds and avoids stacking contexts (frosted-glass/transform would trap overlay z-index).
|
* All chrome uses solid backgrounds and avoids stacking contexts (frosted-glass/transform would trap overlay z-index).
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router";
|
import { NavLink, Outlet, useMatch, useNavigate } from "react-router";
|
||||||
|
import type { SessionInfo } from "@prismshadow/penguin-server/api";
|
||||||
import { S } from "../../lib/strings";
|
import { S } from "../../lib/strings";
|
||||||
import { useAuth } from "../../state/auth";
|
import { useAuth } from "../../state/auth";
|
||||||
|
import { useProject } from "../../state/project";
|
||||||
|
import { useSessions } from "../../state/sessions";
|
||||||
import { Drawer } from "../ui/drawer";
|
import { Drawer } from "../ui/drawer";
|
||||||
import { Sidebar } from "./sidebar";
|
import { GlyphIcon } from "../ui/glyph-icon";
|
||||||
|
import { NAV_ICONS, NEW_CHAT_ICON, Sidebar } from "./sidebar";
|
||||||
|
import { DRAFT_SESSION_ID } from "../../features/chat/chat-page";
|
||||||
import { ChangePasswordDialog } from "../account/change-password-dialog";
|
import { ChangePasswordDialog } from "../account/change-password-dialog";
|
||||||
|
|
||||||
/** Narrow-rail nav icons (matches Sidebar NAV_ICONS). */
|
/** "Last conversation" glyph (chat lines + resume arrow), used only by the rail. */
|
||||||
const RAIL_NAV: ReadonlyArray<{ to: string; label: string; icon: string }> = [
|
const LAST_CHAT_ICON = "M8 10h8M8 14h5M21 12a9 9 0 1 1-4.2-7.6L21 4v5h-5";
|
||||||
{ to: "/chat", label: "chat", icon: "M8 10h8M8 14h5M21 12a9 9 0 1 1-4.2-7.6L21 4v5h-5" },
|
|
||||||
{
|
/** Shared look of rail entries (icon buttons and NavLinks alike): solid gray fill when active, gray hover otherwise. */
|
||||||
to: "/agents",
|
const railItemClass = (active: boolean) =>
|
||||||
label: "agents",
|
`flex h-8 w-8 items-center justify-center rounded-md transition-colors duration-150 ${
|
||||||
icon: "M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01",
|
active
|
||||||
},
|
? "bg-gray-200/70 text-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
||||||
{
|
: "text-gray-500 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||||
to: "/skills",
|
}`;
|
||||||
label: "skills",
|
|
||||||
icon: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z",
|
/**
|
||||||
},
|
* Collapsed narrow rail: expand button on top; below it, in product-specified order, last
|
||||||
{
|
* conversation / new chat / Agents / Skills / Models / Costs / Traces / Benchmark (every entry
|
||||||
to: "/models",
|
* carries a localized title + aria-label, so hover tooltips follow the UI language); user
|
||||||
label: "models",
|
* avatar at the bottom. No Logo shown.
|
||||||
icon: "M7 7h10v10H7zM4 10h3m10 0h3M4 14h3m10 0h3M10 4v3m4-3v3m-4 10v3m4-3v3",
|
*/
|
||||||
},
|
function CollapsedRail({ onExpand }: { onExpand: () => void }) {
|
||||||
{ to: "/usage", label: "usage", icon: "M4 20V10m6 10V4m6 16v-7m4 7H2" },
|
const { user } = useAuth();
|
||||||
{ to: "/traces", label: "traces", icon: "M4 6h16M4 12h10M4 18h13" },
|
const navigate = useNavigate();
|
||||||
];
|
const { agents, setCurrentAgentId } = useProject();
|
||||||
|
const { sessions, loading } = useSessions();
|
||||||
|
const activeSessionId = useMatch("/chat/:sessionId")?.params.sessionId ?? null;
|
||||||
|
/** On some conversation (any non-draft /chat/:id): the "you are here" state of the last-conversation entry. */
|
||||||
|
const onConversation = activeSessionId !== null && activeSessionId !== DRAFT_SESSION_ID;
|
||||||
|
|
||||||
|
/** Newest non-archived Session across the current Project by createdAt (the flat list is only ordered per Agent). */
|
||||||
|
const lastSession = useMemo(
|
||||||
|
() =>
|
||||||
|
sessions.reduce<SessionInfo | null>(
|
||||||
|
(best, s) =>
|
||||||
|
!s.archived && (!best || Date.parse(s.createdAt) > Date.parse(best.createdAt)) ? s : best,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
[sessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Mirrors Sidebar.openSession: the current Agent follows the opened Session's Agent. */
|
||||||
|
const openLastSession = () => {
|
||||||
|
if (!lastSession) return;
|
||||||
|
setCurrentAgentId(lastSession.agentId);
|
||||||
|
navigate(`/chat/${lastSession.sessionId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mirrors the pinned sidebar's "New chat": default_agent draft, falling back to the first Agent (an unresolved list defers resolution to the draft page). */
|
||||||
|
const newChat = () => {
|
||||||
|
const agentId = (agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId;
|
||||||
|
if (agentId) setCurrentAgentId(agentId);
|
||||||
|
navigate(`/chat/${DRAFT_SESSION_ID}`, agentId ? { state: { agentId } } : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Page entries (rail positions 3-8): same routes as the pinned nav; Agents uses the rail-specific short label. */
|
||||||
|
const pages: ReadonlyArray<{ to: string; label: string; icon: string }> = [
|
||||||
|
{ to: "/agents", label: S.nav.railAgents, icon: NAV_ICONS.agents },
|
||||||
|
{ to: "/skills", label: S.nav.skills, icon: NAV_ICONS.skills },
|
||||||
|
{ to: "/models", label: S.nav.models, icon: NAV_ICONS.models },
|
||||||
|
{ to: "/usage", label: S.nav.usage, icon: NAV_ICONS.usage },
|
||||||
|
{ to: "/traces", label: S.nav.traces, icon: NAV_ICONS.traces },
|
||||||
|
{ to: "/benchmark", label: S.nav.benchmark, icon: NAV_ICONS.benchmark },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center gap-1 py-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={S.nav.expandSidebar}
|
||||||
|
aria-label={S.nav.expandSidebar}
|
||||||
|
onClick={onExpand}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<GlyphIcon d="M9 6l6 6-6 6M20 4v16" size={18} />
|
||||||
|
</button>
|
||||||
|
<nav className="mt-1 flex flex-col items-center gap-1">
|
||||||
|
{/* 1. Last conversation: lit on any non-draft conversation. Dimmed/disabled (tooltip kept) only
|
||||||
|
once the list has settled with no non-archived Session — while it is still loading the
|
||||||
|
entry keeps its normal look (no flash) and a click is a graceful no-op. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={S.nav.lastConversation}
|
||||||
|
aria-label={S.nav.lastConversation}
|
||||||
|
disabled={!lastSession && !loading}
|
||||||
|
onClick={openLastSession}
|
||||||
|
className={
|
||||||
|
lastSession || loading
|
||||||
|
? railItemClass(onConversation)
|
||||||
|
: "flex h-8 w-8 cursor-not-allowed items-center justify-center rounded-md text-gray-300 dark:text-gray-700"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GlyphIcon d={LAST_CHAT_ICON} size={18} />
|
||||||
|
</button>
|
||||||
|
{/* 2. New chat: shows the same gray active fill while on the draft page (pinned-sidebar convention). */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={S.chat.newSessionMenu}
|
||||||
|
aria-label={S.chat.newSessionMenu}
|
||||||
|
onClick={newChat}
|
||||||
|
className={railItemClass(activeSessionId === DRAFT_SESSION_ID)}
|
||||||
|
>
|
||||||
|
<GlyphIcon d={NEW_CHAT_ICON} size={18} />
|
||||||
|
</button>
|
||||||
|
{/* 3-8. Page entries */}
|
||||||
|
{pages.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
title={item.label}
|
||||||
|
aria-label={item.label}
|
||||||
|
className={({ isActive }) => railItemClass(isActive)}
|
||||||
|
>
|
||||||
|
<GlyphIcon d={item.icon} size={18} />
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={`${user?.userId ?? ""} · ${S.nav.expandSidebar}`}
|
||||||
|
aria-label={user?.userId ?? S.auth.admin}
|
||||||
|
onClick={onExpand}
|
||||||
|
className="mt-auto flex h-8 w-8 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900"
|
||||||
|
>
|
||||||
|
{(user?.userId ?? "?").slice(0, 1).toUpperCase()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -58,69 +167,7 @@ export function AppLayout() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
// Collapsed narrow rail: expand button on top, icon nav below, user avatar at the bottom; no Logo shown.
|
<CollapsedRail onExpand={toggleCollapsed} />
|
||||||
<div className="flex h-full flex-col items-center gap-1 py-2.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={S.nav.expandSidebar}
|
|
||||||
aria-label={S.nav.expandSidebar}
|
|
||||||
onClick={toggleCollapsed}
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.7"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<path d="M9 6l6 6-6 6M20 4v16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<nav className="mt-1 flex flex-col items-center gap-1">
|
|
||||||
{RAIL_NAV.map((item) => (
|
|
||||||
<NavLink
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
title={item.label}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex h-8 w-8 items-center justify-center rounded-md transition-colors duration-150 ${
|
|
||||||
isActive
|
|
||||||
? "bg-gray-200/70 text-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
: "text-gray-500 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.7"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<path d={item.icon} />
|
|
||||||
</svg>
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={`${user?.userId ?? ""} · ${S.nav.expandSidebar}`}
|
|
||||||
aria-label={user?.userId ?? S.auth.admin}
|
|
||||||
onClick={toggleCollapsed}
|
|
||||||
className="mt-auto flex h-8 w-8 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900"
|
|
||||||
>
|
|
||||||
{(user?.userId ?? "?").slice(0, 1).toUpperCase()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Sidebar onCollapse={toggleCollapsed} />
|
<Sidebar onCollapse={toggleCollapsed} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ function DropdownCaret() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_ICONS = {
|
/** Page-nav glyphs (shared with the collapsed rail in app-layout.tsx). */
|
||||||
|
export const NAV_ICONS = {
|
||||||
agents: "M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01",
|
agents: "M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01",
|
||||||
/** Skill library (an open book: two pages + spine). */
|
/** Skill library (an open book: two pages + spine). */
|
||||||
skills: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z",
|
skills: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z",
|
||||||
@@ -88,6 +89,9 @@ const NAV_ICONS = {
|
|||||||
"M7 4h10v5a5 5 0 0 1-10 0V4zM7 5H4v1a3 3 0 0 0 3 3m10-4h3v1a3 3 0 0 1-3 3M12 14v4m-4 0h8",
|
"M7 4h10v5a5 5 0 0 1-10 0V4zM7 5H4v1a3 3 0 0 0 3 3m10-4h3v1a3 3 0 0 1-3 3M12 14v4m-4 0h8",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** New-chat pencil (the pinned "New chat" button and the collapsed rail share it). */
|
||||||
|
export const NEW_CHAT_ICON = "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z";
|
||||||
|
|
||||||
/** Standard gear (lucide settings): full tooth outline + center circle, crisp and undistorted at 16px. */
|
/** Standard gear (lucide settings): full tooth outline + center circle, crisp and undistorted at 16px. */
|
||||||
const GEAR_ICON =
|
const GEAR_ICON =
|
||||||
"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z";
|
"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z";
|
||||||
@@ -557,7 +561,7 @@ export function Sidebar({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
<Icon d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
<Icon d={NEW_CHAT_ICON} />
|
||||||
</span>
|
</span>
|
||||||
{S.chat.newSessionMenu}
|
{S.chat.newSessionMenu}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export const en: Strings = {
|
|||||||
usage: "Costs",
|
usage: "Costs",
|
||||||
traces: "Trajectory",
|
traces: "Trajectory",
|
||||||
benchmark: "Evaluation Center",
|
benchmark: "Evaluation Center",
|
||||||
|
// Collapsed-rail tooltips (product-specified wording; new chat reuses chat.newSessionMenu, the other pages reuse the page names above).
|
||||||
|
lastConversation: "Last conversation",
|
||||||
|
// Deliberately equal to nav.agents: the key exists for the zh-only wording difference (智能体 vs 智能体仓库).
|
||||||
|
railAgents: "Agents",
|
||||||
collapseSidebar: "Collapse sidebar",
|
collapseSidebar: "Collapse sidebar",
|
||||||
expandSidebar: "Expand sidebar",
|
expandSidebar: "Expand sidebar",
|
||||||
collapseGroup: "Collapse",
|
collapseGroup: "Collapse",
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export const zh = {
|
|||||||
usage: "成本中心",
|
usage: "成本中心",
|
||||||
traces: "轨迹观测",
|
traces: "轨迹观测",
|
||||||
benchmark: "评估中心",
|
benchmark: "评估中心",
|
||||||
|
// Collapsed-rail tooltips (product-specified wording; new chat reuses chat.newSessionMenu, the other pages reuse the page names above).
|
||||||
|
lastConversation: "最近一次对话",
|
||||||
|
railAgents: "智能体",
|
||||||
collapseSidebar: "收起侧栏",
|
collapseSidebar: "收起侧栏",
|
||||||
expandSidebar: "展开侧栏",
|
expandSidebar: "展开侧栏",
|
||||||
collapseGroup: "折叠",
|
collapseGroup: "折叠",
|
||||||
|
|||||||
Reference in new issue
Block a user