From 45b5555b571f68d54092470cd4d7812a212395bb Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Thu, 23 Jul 2026 00:43:51 +0800 Subject: [PATCH] feat(web): collapsed-rail nav with last conversation, new chat, and localized tooltips (#36) Co-authored-by: Alice Co-authored-by: Claude Fable 5 --- packages/web/e2e/layout.spec.mjs | 113 +++++++++ .../web/src/components/layout/app-layout.tsx | 221 +++++++++++------- .../web/src/components/layout/sidebar.tsx | 8 +- packages/web/src/lib/strings-en.ts | 4 + packages/web/src/lib/strings.ts | 3 + 5 files changed, 260 insertions(+), 89 deletions(-) diff --git a/packages/web/e2e/layout.spec.mjs b/packages/web/e2e/layout.spec.mjs index e062b3e..02403cf 100644 --- a/packages/web/e2e/layout.spec.mjs +++ b/packages/web/e2e/layout.spec.mjs @@ -9,6 +9,10 @@ * (the group header's provider name used to get pushed out of the button box and overlap * the group-level actions); * - 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 * 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 @@ -127,6 +131,115 @@ test("layout: en draft + context gauge + mobile models", async ({ page }) => { ).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 }) => { await page.goto(`${BASE}/login`); await page.getByRole("heading", { name: "PenguinHarness" }).waitFor(); diff --git a/packages/web/src/components/layout/app-layout.tsx b/packages/web/src/components/layout/app-layout.tsx index 14d1252..6b97032 100644 --- a/packages/web/src/components/layout/app-layout.tsx +++ b/packages/web/src/components/layout/app-layout.tsx @@ -4,35 +4,144 @@ * - sidebar drawer + brand name) + main content. * All chrome uses solid backgrounds and avoids stacking contexts (frosted-glass/transform would trap overlay z-index). */ -import { useState } from "react"; -import { NavLink, Outlet } from "react-router"; +import { useMemo, useState } from "react"; +import { NavLink, Outlet, useMatch, useNavigate } from "react-router"; +import type { SessionInfo } from "@prismshadow/penguin-server/api"; import { S } from "../../lib/strings"; import { useAuth } from "../../state/auth"; +import { useProject } from "../../state/project"; +import { useSessions } from "../../state/sessions"; 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"; -/** Narrow-rail nav icons (matches Sidebar NAV_ICONS). */ -const RAIL_NAV: ReadonlyArray<{ to: string; label: string; icon: string }> = [ - { to: "/chat", label: "chat", icon: "M8 10h8M8 14h5M21 12a9 9 0 1 1-4.2-7.6L21 4v5h-5" }, - { - to: "/agents", - label: "agents", - 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", - }, - { - 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", - }, - { - to: "/models", - label: "models", - icon: "M7 7h10v10H7zM4 10h3m10 0h3M4 14h3m10 0h3M10 4v3m4-3v3m-4 10v3m4-3v3", - }, - { to: "/usage", label: "usage", icon: "M4 20V10m6 10V4m6 16v-7m4 7H2" }, - { to: "/traces", label: "traces", icon: "M4 6h16M4 12h10M4 18h13" }, -]; +/** "Last conversation" glyph (chat lines + resume arrow), used only by the rail. */ +const LAST_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. */ +const railItemClass = (active: boolean) => + `flex h-8 w-8 items-center justify-center rounded-md transition-colors duration-150 ${ + 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" + }`; + +/** + * 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 + * carries a localized title + aria-label, so hover tooltips follow the UI language); user + * avatar at the bottom. No Logo shown. + */ +function CollapsedRail({ onExpand }: { onExpand: () => void }) { + const { user } = useAuth(); + 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( + (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 ( +
+ + + +
+ ); +} export function AppLayout() { const { user } = useAuth(); @@ -58,69 +167,7 @@ export function AppLayout() { }`} > {collapsed ? ( - // Collapsed narrow rail: expand button on top, icon nav below, user avatar at the bottom; no Logo shown. -
- - - -
+ ) : ( )} diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index d170ebb..14d40a5 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -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", /** 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", @@ -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", } 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. */ 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"; @@ -557,7 +561,7 @@ export function Sidebar({ }`} > - + {S.chat.newSessionMenu} diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index 047e66f..02698da 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -17,6 +17,10 @@ export const en: Strings = { usage: "Costs", traces: "Trajectory", 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", expandSidebar: "Expand sidebar", collapseGroup: "Collapse", diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index f414366..6944a17 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -19,6 +19,9 @@ export const zh = { usage: "成本中心", traces: "轨迹观测", benchmark: "评估中心", + // Collapsed-rail tooltips (product-specified wording; new chat reuses chat.newSessionMenu, the other pages reuse the page names above). + lastConversation: "最近一次对话", + railAgents: "智能体", collapseSidebar: "收起侧栏", expandSidebar: "展开侧栏", collapseGroup: "折叠",