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:
authored and GitHub committed 2026-07-23 00:43:51 +08:00
1 parent 9a1257b2be
commit 45b5555b57
5 files changed
+260 -89

No files matched your search

+113
View File
@@ -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();
+134 -87
View File
@@ -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>
+4
View File
@@ -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",
+3
View File
@@ -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: "折叠",