diff --git a/packages/web/e2e/draft.spec.mjs b/packages/web/e2e/draft.spec.mjs index 56b2fa7..8629ef6 100644 --- a/packages/web/e2e/draft.spec.mjs +++ b/packages/web/e2e/draft.spec.mjs @@ -8,6 +8,8 @@ * "临时工作区" group, a named directory groups under its basename, and that group header's * "+" pre-fills the draft's Workspace (via router state, applied once per navigation — a * manual change made afterwards survives a reload instead of being re-overridden); + * - the group header's hover pin toggle lifts a group above the unpinned ones in its mode and + * persists per Project (order re-checked after a reload); * - after switching the sidebar to agent mode (toggle persisted in localStorage), the agent * group header's "+" creates a draft scoped to that group's Agent (explicitly set via router * state, overriding the cache). @@ -205,6 +207,46 @@ test("draft: pick model/approval -> reload restores them -> send creates the ses await expect(page.getByLabel("Workspace")).toContainText(parentLabel); await expect(page.getByLabel("Workspace")).not.toContainText(wsLabel); + // —— Pinning: the header's hover pin toggle lifts a group above the others in its mode and + // persists per Project (localStorage penguin.sidebarPinnedGroups.); order is + // asserted geometrically, like layout.spec does for the login language buttons. —— + const tempLabel = page.getByText("临时工作区", { exact: true }); + const namedLabel = page.getByText(wsLabel, { exact: true }); + const yOf = async (locator) => (await locator.boundingBox())?.y ?? -1; + // Unpinned baseline: the merged temp group sits below the named group. + await expect(tempLabel).toBeVisible(); + expect(await yOf(tempLabel)).toBeGreaterThan(await yOf(namedLabel)); + const tempHeader = tempLabel.locator("xpath=ancestor::div[contains(@class,'items-center')][1]"); + await tempHeader.hover(); + await tempHeader.getByRole("button", { name: "置顶分组" }).click(); + await expect + .poll(() => + page.evaluate((k) => localStorage.getItem(k), `penguin.sidebarPinnedGroups.${projectId}`), + ) + .toContain("temp-workspaces"); + await expect.poll(async () => (await yOf(tempLabel)) < (await yOf(namedLabel))).toBe(true); + // Pinned state and order survive a reload. The pin button's accessible name is STATIC + // ("置顶分组"); the state lives in aria-pressed alone (review: a name swapping to 取消置顶 + // alongside aria-pressed announces the state twice in conflicting ways). + await page.reload(); + await expect(tempLabel).toBeVisible(); + await expect.poll(async () => (await yOf(tempLabel)) < (await yOf(namedLabel))).toBe(true); + const tempPin = tempHeader.getByRole("button", { name: "置顶分组" }); + await expect(tempPin).toHaveAttribute("aria-pressed", "true"); + // The pinned pin doubles as the always-visible indicator. toBeVisible() ignores opacity, + // so assert computed opacity directly: pinned = 1 with the mouse elsewhere; an unpinned + // header's pin stays hover-gated at 0. Park the mouse first — after the reorder the named + // header sits where the temp header was clicked, which would otherwise hover-reveal it. + await page.mouse.move(640, 500); + await expect.poll(() => tempPin.evaluate((el) => getComputedStyle(el).opacity)).toBe("1"); + await expect + .poll(() => + wsHeader + .getByRole("button", { name: "置顶分组" }) + .evaluate((el) => getComputedStyle(el).opacity), + ) + .toBe("0"); + // —— Switch the sidebar to agent mode via the section-header toggle (persists in localStorage) —— await page.getByRole("button", { name: "按 Agent 分组" }).click(); await expect diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index fed0265..d170ebb 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -2,11 +2,13 @@ * Single-column sidebar, top to bottom: * Project switcher -> new chat (default_agent draft) + fixed nav (Agents / models / cost center / * Trace) -> Session area with two grouping modes (a small toggle in the section header; the - * choice and each Project's group collapse state persist in localStorage): by Workspace (the - * default; groups loaded Sessions by their + * choice and each Project's group collapse and pin state persist in localStorage): by Workspace + * (the default; groups loaded Sessions by their * Workspace path, auto temp directories merged into one trailing group, header "+" starts a * draft in that Workspace) or by Agent (group header = Agent name + new chat + Agent settings; - * shows all Agents, including empty groups) -> bottom user config (theme / language / logout). + * shows all Agents, including empty groups). Groups can be pinned via the header's hover pin + * toggle: pinned groups sort before unpinned within their mode, keeping each partition's own + * order -> bottom user config (theme / language / logout). * Desktop keeps it pinned as the left column; mobile puts the whole thing in a drawer. * New chats always enter draft state (/chat/new, route state specifies the Agent and optionally * the Workspace): Model / Workspace / approval mode are all chosen on the draft input card, so @@ -27,7 +29,11 @@ import { ACCENT_SWATCHES, useTheme } from "../../state/theme"; import type { Accent, Currency, FontScale, ThemeMode } from "../../state/theme"; import { agentDisplayName, projectDisplayName, useProject } from "../../state/project"; import { useSessions } from "../../state/sessions"; -import { groupSessionsByWorkspace, workspaceGroupKey } from "../../lib/session-grouping"; +import { + groupSessionsByWorkspace, + pinnedFirst, + workspaceGroupKey, +} from "../../lib/session-grouping"; import { Dropdown } from "../ui/dropdown"; import { AgentAvatar } from "../ui/agent-avatar"; import { Chevron } from "../ui/chevron"; @@ -86,9 +92,17 @@ const NAV_ICONS = { 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"; -/** Folder outline (same glyph as the draft page's Workspace pill). */ +/** Folder outline, closed (same glyph as the draft page's Workspace pill); collapsed workspace groups and the grouping toggle use it. */ const FOLDER_ICON = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"; +/** Folder outline, open (lucide folder-open: back panel + tilted front flap); expanded workspace groups use it. */ +const FOLDER_OPEN_ICON = + "m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"; + +/** Pushpin (lucide pin: head + body + stem), the group-header pin toggle / pinned indicator. */ +const PIN_ICON = + "M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1z"; + const menuItemClass = "block w-full px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800"; @@ -100,17 +114,19 @@ function initialGroupMode(): GroupMode { } /** - * Collapsed-group persistence (survives a refresh), one key per Project — group keys - * are Agent ids / Workspace paths, which are Project-scoped. Both grouping modes share - * one set (their key spaces never collide); stray keys left by deleted Agents or - * Workspaces are harmless (never matched) and the per-Project sets stay tiny. + * Collapsed-group and pinned-group persistence (survives a refresh), one storage key + * per Project and concern — group keys are Agent ids / Workspace paths, which are + * Project-scoped. Both grouping modes share one set per concern (their key spaces + * never collide); stray keys left by deleted Agents or Workspaces are harmless + * (never matched) and the per-Project sets stay tiny. */ const collapsedGroupsKey = (projectId: string) => `penguin.sidebarCollapsedGroups.${projectId}`; -/** Reads the persisted collapsed set (corrupted storage degrades to "all expanded"). */ -function loadCollapsedGroups(projectId: string | null): ReadonlySet { - if (!projectId) return new Set(); +const pinnedGroupsKey = (projectId: string) => `penguin.sidebarPinnedGroups.${projectId}`; +/** Reads a persisted group-key set (no Project yet / corrupted storage degrade to empty). */ +function loadGroupSet(storageKey: string | null): ReadonlySet { + if (!storageKey) return new Set(); try { - const parsed: unknown = JSON.parse(localStorage.getItem(collapsedGroupsKey(projectId)) ?? "[]"); + const parsed: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]"); return new Set( Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : [], ); @@ -118,10 +134,10 @@ function loadCollapsedGroups(projectId: string | null): ReadonlySet { return new Set(); } } -function saveCollapsedGroups(projectId: string | null, next: ReadonlySet): void { - if (!projectId) return; +function saveGroupSet(storageKey: string | null, next: ReadonlySet): void { + if (!storageKey) return; try { - localStorage.setItem(collapsedGroupsKey(projectId), JSON.stringify([...next])); + localStorage.setItem(storageKey, JSON.stringify([...next])); } catch { /* best-effort persistence (quota/private mode) */ } @@ -179,16 +195,23 @@ export function Sidebar({ const [projectSettingsOpen, setProjectSettingsOpen] = useState(false); const [changePasswordOpen, setChangePasswordOpen] = useState(false); const currentProjectId = currentProject?.projectId ?? null; + const collapseStoreKey = currentProjectId === null ? null : collapsedGroupsKey(currentProjectId); + const pinStoreKey = currentProjectId === null ? null : pinnedGroupsKey(currentProjectId); /** Grouping mode of the Session list (Workspace by default; the choice persists across sessions). */ const [groupMode, setGroupModeState] = useState(initialGroupMode); /** Collapsed groups (expanded by default), keyed by Agent id or Workspace group key depending on the mode; persisted per Project. */ const [collapsedGroups, setCollapsedGroups] = useState>(() => - loadCollapsedGroups(currentProjectId), + loadGroupSet(collapseStoreKey), ); - // Project resolved on first load / switched: swap in that Project's persisted collapse set. + /** Pinned groups (sorted before unpinned within their mode), keyed like collapsedGroups; persisted per Project. */ + const [pinnedGroups, setPinnedGroups] = useState>(() => + loadGroupSet(pinStoreKey), + ); + // Project resolved on first load / switched: swap in that Project's persisted collapse/pin sets. useEffect(() => { - setCollapsedGroups(loadCollapsedGroups(currentProjectId)); - }, [currentProjectId]); + setCollapsedGroups(loadGroupSet(collapseStoreKey)); + setPinnedGroups(loadGroupSet(pinStoreKey)); + }, [collapseStoreKey, pinStoreKey]); /** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */ const [openArchived, setOpenArchived] = useState>(new Set()); /** Session pending delete confirmation (null = none). */ @@ -209,6 +232,17 @@ export function Sidebar({ /** Workspace groups (workspace mode): computed from the flat list, temp directories merged last. */ const workspaceGroups = useMemo(() => groupSessionsByWorkspace(sessions), [sessions]); + // Pinned groups first within each mode; inside each partition the existing order is kept + // (recency for Workspace groups, the configured Agent order for Agents). + const orderedAgents = useMemo( + () => pinnedFirst(agents, (a) => a.agentId, pinnedGroups), + [agents, pinnedGroups], + ); + const orderedWorkspaceGroups = useMemo( + () => pinnedFirst(workspaceGroups, (g) => g.key, pinnedGroups), + [workspaceGroups, pinnedGroups], + ); + /** Group key of a Session under the current mode (collapse / archived-open state). */ const sessionGroupKey = (s: SessionInfo) => groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace); @@ -220,7 +254,16 @@ export function Sidebar({ if (next.has(key)) next.delete(key); else next.add(key); setCollapsedGroups(next); - saveCollapsedGroups(currentProjectId, next); + saveGroupSet(collapseStoreKey, next); + }; + + /** Pin / unpin a group (same toggle-and-persist convention as toggleGroup). */ + const togglePin = (key: string) => { + const next = new Set(pinnedGroups); + if (next.has(key)) next.delete(key); + else next.add(key); + setPinnedGroups(next); + saveGroupSet(pinStoreKey, next); }; const toggleArchivedGroup = (key: string) => @@ -576,20 +619,24 @@ export function Sidebar({ loading && agents.length === 0 ? ( ) : ( - agents.map((agent) => { + orderedAgents.map((agent) => { const list = byAgent.get(agent.agentId) ?? []; const activeList = list.filter((s) => !s.archived); const archivedList = list.filter((s) => s.archived); const collapsed = collapsedGroups.has(agent.agentId); + const pinned = pinnedGroups.has(agent.agentId); return (
- {/* Group header: collapse toggle (Agent name) + new chat + Agent settings */} -
+ {/* Group header: collapse toggle (Agent name) + pin + new chat + Agent settings. + self-stretch makes the collapse toggle's hover pill span the full row height + set by the h-7 action buttons (one consistent hover geometry). */} +
+ togglePin(agent.agentId)} /> {/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */} + togglePin(group.key)} /> {/* New chat in this Workspace: pre-fills the group's path in the draft ("" = auto temp directory); the Agent is the current one, falling back to default_agent */} + ); +} + /** Single Session row: title + status dot/approval badge + hover action group (rename, archive/unarchive, delete). */ function SessionRow({ s, diff --git a/packages/web/src/lib/session-grouping.ts b/packages/web/src/lib/session-grouping.ts index c5870ae..04f0ba8 100644 --- a/packages/web/src/lib/session-grouping.ts +++ b/packages/web/src/lib/session-grouping.ts @@ -89,3 +89,21 @@ export function groupSessionsByWorkspace(sessions: SessionInfo[]): WorkspaceGrou }); return groups; } + +/** + * Stable pinned-first partition for sidebar groups: items whose key is in `pinned` + * come first, each partition preserving the input order (recency for Workspace + * groups, the configured order for Agents). Pure and mode-agnostic — callers pass + * the key extractor; pinned keys with no matching item are simply ignored. + */ +export function pinnedFirst( + items: readonly T[], + keyOf: (item: T) => string, + pinned: ReadonlySet, +): T[] { + if (pinned.size === 0) return [...items]; + const pin: T[] = []; + const rest: T[] = []; + for (const item of items) (pinned.has(keyOf(item)) ? pin : rest).push(item); + return [...pin, ...rest]; +} diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index 858a8aa..047e66f 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -21,6 +21,8 @@ export const en: Strings = { expandSidebar: "Expand sidebar", collapseGroup: "Collapse", expandGroup: "Expand", + pinGroup: "Pin group", + unpinGroup: "Unpin group", }, settings: { diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index 4f2b18c..f414366 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -23,6 +23,8 @@ export const zh = { expandSidebar: "展开侧栏", collapseGroup: "折叠", expandGroup: "展开", + pinGroup: "置顶分组", + unpinGroup: "取消置顶", }, settings: { diff --git a/packages/web/test/session-grouping.test.ts b/packages/web/test/session-grouping.test.ts index 73cf7da..43fb770 100644 --- a/packages/web/test/session-grouping.test.ts +++ b/packages/web/test/session-grouping.test.ts @@ -14,6 +14,7 @@ import { TEMP_WORKSPACE_GROUP_KEY, groupSessionsByWorkspace, isTempWorkspace, + pinnedFirst, workspaceGroupKey, workspaceLabel, } from "../src/lib/session-grouping"; @@ -136,3 +137,57 @@ describe("groupSessionsByWorkspace", () => { expect(groups[0]!.sessions).toHaveLength(2); }); }); + +describe("pinnedFirst (stable pinned-before-unpinned partition)", () => { + const items = [{ k: "a" }, { k: "b" }, { k: "c" }, { k: "d" }]; + const keyOf = (i: { k: string }) => i.k; + const keys = (out: { k: string }[]) => out.map((i) => i.k); + + it("empty pinned set keeps the order (and returns a copy, never the input array)", () => { + const out = pinnedFirst(items, keyOf, new Set()); + expect(keys(out)).toEqual(["a", "b", "c", "d"]); + expect(out).not.toBe(items); + }); + + it("pinned items move to the front, each partition preserving the input order", () => { + expect(keys(pinnedFirst(items, keyOf, new Set(["c", "a"])))).toEqual(["a", "c", "b", "d"]); + expect(keys(pinnedFirst(items, keyOf, new Set(["d"])))).toEqual(["d", "a", "b", "c"]); + }); + + it("pinned keys with no matching item are ignored; all-pinned keeps the order", () => { + expect(keys(pinnedFirst(items, keyOf, new Set(["nope"])))).toEqual(["a", "b", "c", "d"]); + expect(keys(pinnedFirst(items, keyOf, new Set(["a", "b", "c", "d"])))).toEqual([ + "a", + "b", + "c", + "d", + ]); + }); + + it("pinning the merged temp group lifts it above named workspace groups", () => { + const groups = groupSessionsByWorkspace([ + session("/srv/alpha", "2026-07-02T10:00:00.000Z"), + session(TEMP_A, "2026-07-01T10:00:00.000Z"), + ]); + expect(groups.map((g) => g.key)).toEqual(["/srv/alpha", TEMP_WORKSPACE_GROUP_KEY]); + const pinned = pinnedFirst(groups, (g) => g.key, new Set([TEMP_WORKSPACE_GROUP_KEY])); + expect(pinned.map((g) => g.key)).toEqual([TEMP_WORKSPACE_GROUP_KEY, "/srv/alpha"]); + }); + + it("agent-mode ordering: pinned agentIds lift agents, the unpinned keep the configured order", () => { + // The sidebar's agent mode partitions the Project's Agent list (agentId is the group key). + const agents = [{ agentId: "default_agent" }, { agentId: "agent_a" }, { agentId: "agent_b" }]; + const byId = (a: { agentId: string }) => a.agentId; + expect(pinnedFirst(agents, byId, new Set(["agent_b"])).map(byId)).toEqual([ + "agent_b", + "default_agent", + "agent_a", + ]); + // Multiple pinned Agents keep their relative configured order inside the pinned partition. + expect(pinnedFirst(agents, byId, new Set(["agent_b", "default_agent"])).map(byId)).toEqual([ + "default_agent", + "agent_b", + "agent_a", + ]); + }); +});