feat(web): sidebar group polish — even hover geometry, folder open/close icons, group pinning (#35)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-23 00:35:41 +08:00
committed by GitHub
parent 78a8b35793
commit 829ca57a43
6 changed files with 234 additions and 31 deletions
+115 -31
View File
@@ -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<string> {
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<string> {
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<string> {
return new Set();
}
}
function saveCollapsedGroups(projectId: string | null, next: ReadonlySet<string>): void {
if (!projectId) return;
function saveGroupSet(storageKey: string | null, next: ReadonlySet<string>): 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<GroupMode>(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<ReadonlySet<string>>(() =>
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<ReadonlySet<string>>(() =>
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<ReadonlySet<string>>(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 ? (
<SkeletonList rows={5} />
) : (
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 (
<div key={agent.agentId} className="pt-2.5">
{/* Group header: collapse toggle (Agent name) + new chat + Agent settings */}
<div className="flex items-center gap-0.5 px-1 pb-0.5">
{/* 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). */}
<div className="group/header flex items-center gap-0.5 px-1 pb-0.5">
<button
type="button"
onClick={() => toggleGroup(agent.agentId)}
aria-expanded={!collapsed}
aria-label={collapsed ? S.nav.expandGroup : S.nav.collapseGroup}
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
className="flex min-w-0 flex-1 items-center gap-1 self-stretch rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
>
<AgentAvatar
id={agent.agentId}
@@ -604,6 +651,7 @@ export function Sidebar({
<Chevron open={!collapsed} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" />
</button>
<GroupPinButton pinned={pinned} onToggle={() => togglePin(agent.agentId)} />
{/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */}
<button
type="button"
@@ -633,28 +681,33 @@ export function Sidebar({
)
) : loading && sessions.length === 0 ? (
<SkeletonList rows={5} />
) : workspaceGroups.length === 0 ? (
) : orderedWorkspaceGroups.length === 0 ? (
<p className="px-2.5 pt-3 text-xs text-gray-400 dark:text-gray-600">
{S.chat.noSessions}
</p>
) : (
workspaceGroups.map((group) => {
orderedWorkspaceGroups.map((group) => {
const activeList = group.sessions.filter((s) => !s.archived);
const archivedList = group.sessions.filter((s) => s.archived);
const collapsed = collapsedGroups.has(group.key);
const pinned = pinnedGroups.has(group.key);
return (
<div key={group.key} className="pt-2.5">
{/* Group header: collapse toggle (folder icon + directory basename + count, full path in the tooltip) + new chat in this Workspace */}
<div className="flex items-center gap-0.5 px-1 pb-0.5">
{/* Group header: collapse toggle (folder icon + directory basename + count, full path in the tooltip) + pin + new chat in this Workspace.
self-stretch: without it the collapse toggle's hover pill is content-sized
(~20px) and sits visibly shorter than the h-7 action buttons beside it. */}
<div className="group/header flex items-center gap-0.5 px-1 pb-0.5">
<button
type="button"
onClick={() => toggleGroup(group.key)}
aria-expanded={!collapsed}
aria-label={collapsed ? S.nav.expandGroup : S.nav.collapseGroup}
{...(group.fullPath !== null ? { title: group.fullPath } : {})}
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
className="flex min-w-0 flex-1 items-center gap-1 self-stretch rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
>
{/* Folder opens and closes with the group */}
<span className="shrink-0 text-gray-400 dark:text-gray-500">
<Icon d={FOLDER_ICON} size={15} />
<Icon d={collapsed ? FOLDER_ICON : FOLDER_OPEN_ICON} size={15} />
</span>
{/* No uppercase transform: a directory basename's casing is meaningful */}
<span className="min-w-0 truncate text-xs font-semibold text-gray-500 dark:text-gray-400">
@@ -666,6 +719,7 @@ export function Sidebar({
<Chevron open={!collapsed} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" />
</button>
<GroupPinButton pinned={pinned} onToggle={() => 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 */}
<button
type="button"
@@ -854,6 +908,36 @@ export function Sidebar({
);
}
/**
* Group-header pin toggle, shared by both grouping modes: revealed on header hover (or
* keyboard focus) while unpinned; once pinned it stays visible, doubling as the subtle
* pinned indicator. The header row carries the `group/header` scope so the reveal only
* reacts to its own row, not to the session rows' plain `group` scope.
* The accessible name stays STATIC and aria-pressed alone carries the state (the toggle
* pattern the grouping-mode buttons use) — a name that swaps 置顶/取消置顶 alongside
* aria-pressed reads as "Unpin group, pressed", saying the state twice in conflicting
* ways. The title tooltip may still swap: it is presentation for pointer users and does
* not feed the accessible name while aria-label is present.
*/
function GroupPinButton({ pinned, onToggle }: { pinned: boolean; onToggle: () => void }) {
return (
<button
type="button"
title={pinned ? S.nav.unpinGroup : S.nav.pinGroup}
aria-label={S.nav.pinGroup}
aria-pressed={pinned}
onClick={onToggle}
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-all duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200 ${
pinned
? "text-gray-500 dark:text-gray-400"
: "text-gray-400 opacity-0 focus-visible:opacity-100 group-hover/header:opacity-100 dark:text-gray-500"
}`}
>
<Icon d={PIN_ICON} size={15} />
</button>
);
}
/** Single Session row: title + status dot/approval badge + hover action group (rename, archive/unarchive, delete). */
function SessionRow({
s,